mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
A branch copied the parent's frozen policy, so session/fork of a Don't Ask session started in Don't Ask. Main's fork_session built a fresh state at Default; _child_policy now resets edit_approval_policy to ask. Refs #134889 (ACP-7)
105 lines
6.5 KiB
Python
105 lines
6.5 KiB
Python
"""An independent local route, transcript and frozen policy in one receipt commit."""
|
|
import json
|
|
import uuid
|
|
|
|
from hermes_state_local import POLICY_PREFIX
|
|
from hermes_state_local_lineage import validate_local_lineage
|
|
from hermes_state_mutation_guards import require_idle
|
|
from hermes_state_runtime import RuntimeStoreError, _json
|
|
from hermes_state_titles import lineage_title_on_conn
|
|
|
|
|
|
def _branch_boundary(conn, target, through):
|
|
"""Highest row id the branch copies. ``through`` must name an active row of the physical
|
|
target (after compression that is the continuation, whose ids the viewer was shown); rows
|
|
appended later, even in this same commit window, have larger ids and stay out. The tool
|
|
results that directly follow an assistant boundary answer its calls, so they belong to it."""
|
|
if through is None:
|
|
return conn.execute('SELECT COALESCE(MAX(id), 0) FROM messages WHERE session_id=? AND active=1',
|
|
(target,)).fetchone()[0]
|
|
row = conn.execute('SELECT role FROM messages WHERE id=? AND session_id=? AND active=1',
|
|
(through, target)).fetchone()
|
|
if row is None:
|
|
raise RuntimeStoreError('invalid_params')
|
|
if row['role'] == 'assistant':
|
|
following = conn.execute('SELECT id, role FROM messages WHERE session_id=? AND active=1 AND id>? '
|
|
'ORDER BY id', (target, through))
|
|
for later in following:
|
|
if later['role'] != 'tool':
|
|
break
|
|
through = later['id']
|
|
return through
|
|
|
|
|
|
def _child_policy(policy, child, prepared):
|
|
"""The parent's frozen policy, except editor MCP servers: the gateway prepared the child's own
|
|
selection under the child's own scope (gateway/session_local_mcp.py ``prepare_branch_editor``).
|
|
Copying the parent's would share its scope, borrowed credentials and live connections.
|
|
A branch starts at Default (``ask``), as main's ACP ``fork_session`` did."""
|
|
if 'edit_approval_policy' in policy:
|
|
policy = {**policy, 'edit_approval_policy': 'ask'}
|
|
if prepared is None:
|
|
if policy.get('editor_mcp_json'):
|
|
raise RuntimeStoreError('runtime_coordination_required')
|
|
return policy
|
|
# Prepared against the parent's selection and the child id this commit derives; anything else moved.
|
|
if child != prepared['child'] or policy.get('editor_mcp_json') != prepared['parent_editor_mcp_json']:
|
|
raise RuntimeStoreError('revision_conflict')
|
|
return {**policy, 'editor_mcp_json': prepared['editor_mcp_json'], 'toolsets': prepared['toolsets'],
|
|
'config_secret_ref': prepared['config_secret_ref']}
|
|
|
|
|
|
def branch_in_transaction(db, conn, session_id, payload, prepared=None):
|
|
row = conn.execute('SELECT value FROM state_meta WHERE key=?',
|
|
(POLICY_PREFIX + session_id,)).fetchone()
|
|
if row is None:
|
|
raise RuntimeStoreError('runtime_coordination_required')
|
|
saved = json.loads(row[0])
|
|
target = validate_local_lineage(conn, saved)
|
|
require_idle(db, conn, list({session_id, target}))
|
|
# Late: the routing entry/key are gateway types (the gateway imports this store at module level).
|
|
from gateway.session import SessionEntry, SessionSource
|
|
from gateway.config import Platform
|
|
from hermes_state_keys import local_identity, profile_from_session_key
|
|
request_id = prepared['request_id'] if prepared else 'branch:' + uuid.uuid4().hex
|
|
child = local_identity(saved['profile_id'], saved['principal_id'], request_id)
|
|
policy = _child_policy(saved['policy'], child, prepared)
|
|
# The child keeps the parent's owning profile: a served secondary's origin names it
|
|
# (``local_source``), and restore compares the stored origin against that exactly.
|
|
source = SessionSource(Platform.LOCAL, child, user_id=saved['principal_id'], chat_type='dm',
|
|
profile=saved['entry']['origin'].get('profile'))
|
|
from gateway.session import build_session_key
|
|
route = build_session_key(source, profile=profile_from_session_key(saved['route']))
|
|
from gateway.session_lifecycle import _now
|
|
now = _now()
|
|
entry = SessionEntry(route, child, now, now, origin=source, platform=Platform.LOCAL)
|
|
parent = conn.execute('SELECT * FROM sessions WHERE id=?', (target,)).fetchone()
|
|
db._publish_child_session_row(conn, parent, parent_session_id=target,
|
|
child_session_id=child, source=policy['source'], model=policy['model'],
|
|
model_config={'_branched_from': target}, system_prompt=None,
|
|
cwd=policy['cwd'], profile_name=parent['profile_name'])
|
|
conn.execute('UPDATE sessions SET session_key=?,chat_id=?,origin_json=?,system_prompt_hash=? WHERE id=?',
|
|
(route, child, _json(source.to_dict()), parent['system_prompt_hash'], child))
|
|
ids, tools = db._tail_rows_after_watermark(conn,
|
|
'SELECT id,tool_calls FROM messages WHERE session_id=? AND active=1 AND id<=? ORDER BY id',
|
|
[target, _branch_boundary(conn, target, payload.get('through_message_id'))])
|
|
db._clone_message_rows(conn, ids, session_id=child)
|
|
conn.execute('UPDATE sessions SET message_count=?,tool_call_count=? WHERE id=?', (len(ids), tools, child))
|
|
if payload.get('title'):
|
|
db._set_session_title_in_transaction(conn, child, payload['title'], source=db.TITLE_SOURCE_USER)
|
|
else:
|
|
# Untitled branch: the next name in the parent's lineage ("my session" → "my session #2"),
|
|
# same rule as the legacy TUI /branch, so the child is its own titled sidebar row.
|
|
db._set_session_title_in_transaction(conn, child, lineage_title_on_conn(conn, parent['title'] or 'branch'),
|
|
source=db.TITLE_SOURCE_DERIVED)
|
|
receipt = dict(profile_id=saved['profile_id'], principal_id=saved['principal_id'],
|
|
request_id=request_id, session_id=child, route=route, entry=entry.to_dict(), policy=policy)
|
|
# The copied policy holds the parent's picks; the child's refresh must keep them as picks, not defaults.
|
|
# Always written: a receipt without it predates the field, and picks_on reads that one's lineage.
|
|
from hermes_state_mutation_prepared import picks_on
|
|
receipt['picks'] = sorted(picks_on(conn, session_id))
|
|
conn.execute("INSERT INTO gateway_routing(scope,session_key,entry_json,updated_at) VALUES('',?,?,?)",
|
|
(route, _json(entry.to_dict()), now.timestamp()))
|
|
conn.execute('INSERT INTO state_meta(key,value) VALUES(?,?)', (POLICY_PREFIX + child, _json(receipt)))
|
|
return {session_id}, {'branched_session_id': child, 'copied_messages': len(ids)}
|