mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
fix(gateway,cli): the session row's cwd is main's stamp, split from the execution cwd
Main stamps sessions.cwd only from the agent's own lazy create (run_agent._launch_cwd_for_session: the process cwd, CLI-family label, local terminal backend). /new and --create-if-missing rows stay empty, and -w records the repo root because main never chdirs into the worktree (it sets TERMINAL_CWD). The gateway wrote the policy cwd (the execution dir) on every row, so a piped bare `-c` (workspace-scoped MRU) picked a newer /new or titled session that main skips as cwd-less. Split row metadata from runtime cwd rather than changing execution: - session.create takes an optional `row_cwd` (canonical contract + CREATE_FIELDS; absolute or "" = none). It is frozen in request_json; hermes_state_local._row_cwd writes it to the row at the creation commit, else the execution cwd as before (every other surface is unchanged). policy.cwd / TERMINAL_CWD are untouched: -w tools still run in the tree. - GatewaySessionStore.create sends main's stamp: os.getcwd() for a CLI-family label on a local backend, "" for /new (CLIInitMixin._new_session_id) and --create-if-missing (create_titled_session, record_cwd=False) and for remote backends. - TS/OpenRPC regenerated with gen_gateway_contracts.py. Reset/compress/branch children keep copying policy['cwd'] (main's children inherit the parent's row cwd via the compression/branch path; not exercised by the harness). Red first: tests/gateway/test_session_row_cwd.py (row vs policy cwd, contract) and test_gateway_session_store::test_row_cwd_is_mains_stamp... failed before the change. (cherry picked from commit 532c257783f20ebe603a74b81e3551a7091feecf)
This commit is contained in:
1 parent
650b96e892
commit
e504e030e3
9 files changed
+136
-7
No files matched your search
@@ -4775,6 +4775,7 @@ export interface CanonicalCreateParams {
|
||||
request_id?: string | null
|
||||
source?: 'cli' | 'tui' | 'gui' | 'acp' | 'tool' | 'oneshot'
|
||||
cwd?: string | null
|
||||
row_cwd?: string | null
|
||||
model?: string | null
|
||||
provider?: string | null
|
||||
base_url?: string | null
|
||||
|
||||
@@ -8587,6 +8587,18 @@
|
||||
"default": null,
|
||||
"title": "Cwd"
|
||||
},
|
||||
"row_cwd": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Row Cwd"
|
||||
},
|
||||
"model": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
||||
@@ -11,7 +11,7 @@ CREATE_FIELDS = frozenset({'request_id', 'source', 'cwd', 'model', 'toolsets',
|
||||
'provider', 'base_url', 'reasoning', 'max_turns', 'ignore_rules', 'api_key', 'editor',
|
||||
'yolo', 'safe_mode', 'ignore_user_config',
|
||||
'skills', 'checkpoints', 'accept_hooks', 'pass_session_id', 'follow_profile_config',
|
||||
'service_tier', 'cwd_explicit', 'run_budget', 'verbose'})
|
||||
'service_tier', 'cwd_explicit', 'run_budget', 'verbose', 'row_cwd'})
|
||||
# Sections a ``follow_profile_config`` session re-reads from the owning profile's CURRENT config each
|
||||
# turn (main's Bot Chat row), credentials included: a frozen key never crosses a profile route change.
|
||||
FOLLOW_SECTIONS = ('model', 'providers', 'custom_providers', 'fallback_providers', 'fallback_model', 'model_aliases',
|
||||
@@ -263,6 +263,9 @@ def build_policy(params, config, *, private_secrets=None, profile_terminal=True)
|
||||
cwd = params.get('cwd', str(resolve_agent_cwd()))
|
||||
if not isinstance(cwd, str) or not Path(cwd).is_absolute() or not Path(cwd).is_dir():
|
||||
raise RuntimeStoreError('invalid_params')
|
||||
row_cwd = params.get('row_cwd')
|
||||
if 'row_cwd' in params and (not isinstance(row_cwd, str) or (row_cwd and not Path(row_cwd).is_absolute())):
|
||||
raise RuntimeStoreError('invalid_params')
|
||||
if params.get('cwd_explicit') is False and 'cwd' in params:
|
||||
cwd = _profile_workspace_cwd(get_hermes_home()) or cwd
|
||||
from gateway.session_local_editor import validate_editor
|
||||
|
||||
@@ -393,7 +393,7 @@ class CLIInitMixin:
|
||||
return store.branch(branch_of, title)
|
||||
sid = store.create({"toolsets": getattr(self, "enabled_toolsets", None),
|
||||
"pass_session_id": getattr(self, "pass_session_id", False) or None,
|
||||
"ignore_rules": getattr(self, "ignore_rules", False) or None})
|
||||
"ignore_rules": getattr(self, "ignore_rules", False) or None}, record_cwd=False)
|
||||
self.session_id = sid
|
||||
return sid
|
||||
|
||||
|
||||
@@ -135,14 +135,19 @@ class GatewaySessionStore:
|
||||
raise
|
||||
return self.create(launch)
|
||||
|
||||
def create(self, launch=None, title=None):
|
||||
def create(self, launch=None, title=None, *, record_cwd=True):
|
||||
"""``session.create`` with the launch policy (and ``title``: the owner's resolve -> create ->
|
||||
title step); adopts and returns the new session id."""
|
||||
from agent.session_source import session_source_for
|
||||
title step); adopts and returns the new session id. ``record_cwd=False`` (``/new``,
|
||||
``--create-if-missing``) leaves the row's cwd empty, as main's explicit creates did."""
|
||||
from agent.session_source import CLI_FAMILY_SOURCES, session_source_for
|
||||
params = _creation_policy(launch or {})
|
||||
params["cwd"] = os.path.realpath(os.getenv("TERMINAL_CWD") or os.getcwd())
|
||||
# Main's row label: ``cli``, ``oneshot`` for a finite -q/-Q run, or an explicit --source.
|
||||
params["source"] = session_source_for("cli")
|
||||
# Main's row stamp (run_agent._launch_cwd_for_session): this process's cwd (``-w`` never
|
||||
# chdirs, so the repo root), only for a CLI-family label on a local terminal backend.
|
||||
local = (os.getenv("TERMINAL_ENV") or "local").strip().lower() in ("", "local")
|
||||
params["row_cwd"] = os.getcwd() if record_cwd and local and params["source"] in CLI_FAMILY_SOURCES else ""
|
||||
if title:
|
||||
params["title"] = title
|
||||
snapshot = self.call("session.create", request_id=uuid.uuid4().hex, **params)
|
||||
@@ -398,4 +403,4 @@ def create_titled_session(title):
|
||||
"""``chat -c NAME --create-if-missing`` (main's ``_create_titled_session``): the gateway creates the
|
||||
titled session; the CLI resumes the id it returns. Launch flags are applied by the CLI's own
|
||||
session open, as main's bare row took them at agent build."""
|
||||
return session_store().create(title=title)
|
||||
return session_store().create(title=title, record_cwd=False)
|
||||
+13
-1
@@ -49,7 +49,7 @@ def commit_local_session(db, *, epoch, receipt):
|
||||
model,cwd,profile_name,origin_json,started_at) VALUES(?,?,?,?,?,'dm',?,?,?,?,?)
|
||||
ON CONFLICT(id) DO NOTHING''',
|
||||
(sid, policy['source'], receipt['principal_id'], route, entry['origin']['chat_id'],
|
||||
policy['model'], policy['cwd'], db._own_profile_name(), _json(entry['origin']), started))
|
||||
policy['model'], _row_cwd(policy), db._own_profile_name(), _json(entry['origin']), started))
|
||||
conn.execute("INSERT INTO gateway_routing(scope,session_key,entry_json,updated_at) VALUES('',?,?,?)",
|
||||
(route, _json(entry), started))
|
||||
conn.execute('INSERT INTO state_meta(key,value) VALUES(?,?)', (key, encoded))
|
||||
@@ -57,6 +57,18 @@ def commit_local_session(db, *, epoch, receipt):
|
||||
return db._execute_write(write)
|
||||
|
||||
|
||||
def _row_cwd(policy):
|
||||
"""The sessions-row ``cwd``: the create request's ``row_cwd`` (``""`` = none) when it sent one,
|
||||
else the execution cwd. Row metadata only (workspace grouping, ``-c``, cwd restore); the
|
||||
frozen policy's ``cwd`` / ``TERMINAL_CWD`` stay where the session's tools run."""
|
||||
try:
|
||||
request = json.loads(policy.get('request_json') or '{}')
|
||||
except (TypeError, ValueError):
|
||||
request = {}
|
||||
value = request.get('row_cwd') if isinstance(request, dict) else None
|
||||
return policy['cwd'] if not isinstance(value, str) else (value or None)
|
||||
|
||||
|
||||
def _policy_target(raw):
|
||||
"""Current physical target a creation receipt routes to; None for a malformed receipt, which
|
||||
then stays (its own reads already refuse it as ``storage_unavailable``) and cannot abort
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Session-row ``cwd`` is row metadata, split from the execution cwd: the classic CLI records what
|
||||
main's own lazy create stamped (``row_cwd``: the launch dir, or none for ``/new`` and
|
||||
``--create-if-missing``) while the frozen policy keeps the directory its tools run in."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _authority_fixture(tmp_path, monkeypatch):
|
||||
from gateway import run
|
||||
from gateway.config import GatewayConfig
|
||||
from gateway.session import SessionStore
|
||||
monkeypatch.setattr(run, '_load_gateway_config', lambda: {'platform_toolsets': {'cli': []}})
|
||||
store = SessionStore(tmp_path / 'sessions', GatewayConfig())
|
||||
runner = SimpleNamespace(session_store=store, _session_db=store._db, adapters={}, _draining=False,
|
||||
_cached_agent_for=lambda route: None)
|
||||
runner._adapter_for_source = lambda source: runner.adapters.get(source.platform)
|
||||
return store, runner
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_row_cwd_is_recorded_apart_from_the_execution_cwd(tmp_path, monkeypatch):
|
||||
from gateway.session_authority import initialize_session_authority
|
||||
from gateway.session_controls import AuthorityConnection
|
||||
from gateway.session_local import create_local_session
|
||||
from gateway.session_policy import restore_policy
|
||||
from hermes_state_local import local_receipt
|
||||
|
||||
store, runner = _authority_fixture(tmp_path, monkeypatch)
|
||||
authority = await initialize_session_authority(runner, profile_id='fixture', instance_id='owner')
|
||||
actor = AuthorityConnection(authority, object(), {'user_id': 'human', 'profile_id': 'fixture'}).actor
|
||||
work = tmp_path / 'repo' / '.worktrees' / 'wt'
|
||||
work.mkdir(parents=True)
|
||||
try:
|
||||
rows = {}
|
||||
for name, extra in (('default', {}), ('blank', {'row_cwd': ''}),
|
||||
('repo', {'row_cwd': str(tmp_path / 'repo')})):
|
||||
ref = create_local_session(authority, actor, {'request_id': name, 'source': 'cli', 'cwd': str(work),
|
||||
'model': 'm', 'toolsets': [], **extra})
|
||||
receipt = local_receipt(authority.db, ref.session_id)
|
||||
policy = restore_policy(receipt['policy'])
|
||||
assert policy.cwd == str(work) # the tools still run in the launch/worktree dir
|
||||
rows[name] = authority.db.get_session(receipt['entry']['session_id'])['cwd']
|
||||
assert rows == {'default': str(work), 'blank': None, 'repo': str(tmp_path / 'repo')}
|
||||
with pytest.raises(Exception):
|
||||
create_local_session(authority, actor, {'request_id': 'bad', 'source': 'cli', 'cwd': str(work),
|
||||
'model': 'm', 'toolsets': [], 'row_cwd': 'relative/dir'})
|
||||
finally:
|
||||
store.close_all_db_handles()
|
||||
|
||||
|
||||
def test_canonical_create_contract_accepts_row_cwd():
|
||||
from tui_gateway.contracts.registry import CANONICAL_METHODS, canonical_param_problems
|
||||
contract = CANONICAL_METHODS['session.create']
|
||||
assert not canonical_param_problems(contract, {'source': 'cli', 'row_cwd': ''})
|
||||
@@ -406,3 +406,41 @@ def test_new_session_before_the_gateway_session_opened_is_mains_placeholder(stor
|
||||
host = types.SimpleNamespace(session_id="x", _session_db=db, _gateway_session_open=False)
|
||||
assert CLIInitMixin._new_session_id(host, datetime(2026, 10, 12, 1, 2, 3)).startswith("20261012_010203_")
|
||||
assert not [c for c in client.calls if c[0] == "session.create"]
|
||||
|
||||
|
||||
def test_run_budget_flag_rides_session_create():
|
||||
from hermes_cli.gateway_session_store import _creation_policy
|
||||
assert _creation_policy({"run_budget": 90})["run_budget"] == 90.0
|
||||
assert "run_budget" not in _creation_policy({"run_budget": None})
|
||||
|
||||
|
||||
def test_verbose_flag_rides_session_create():
|
||||
from hermes_cli.gateway_session_store import _creation_policy
|
||||
assert _creation_policy({"verbose": True})["verbose"] is True
|
||||
assert "verbose" not in _creation_policy({"verbose": None})
|
||||
|
||||
|
||||
def test_row_cwd_is_mains_stamp_while_the_tools_run_in_terminal_cwd(store, monkeypatch, tmp_path):
|
||||
"""Main's row cwd: the launching process's cwd on its first session (``-w`` never chdirs, so the
|
||||
repo root), none on ``/new`` and ``--create-if-missing``. The execution cwd stays TERMINAL_CWD."""
|
||||
from hermes_cli.cli_init_mixin import CLIInitMixin
|
||||
from hermes_cli import gateway_session_store as gss
|
||||
db, client = store
|
||||
repo, tree = tmp_path / "repo", tmp_path / "repo" / ".worktrees" / "wt"
|
||||
tree.mkdir(parents=True)
|
||||
monkeypatch.chdir(repo)
|
||||
monkeypatch.setenv("TERMINAL_CWD", str(tree))
|
||||
monkeypatch.delenv("TERMINAL_ENV", raising=False)
|
||||
monkeypatch.delenv("HERMES_SESSION_SOURCE", raising=False)
|
||||
db.open_session(cli(), {"toolsets": ["file"]})
|
||||
first = [p for m, p in client.calls if m == "session.create"][-1]
|
||||
assert first["cwd"] == str(tree.resolve()) and first["row_cwd"] == str(repo.resolve())
|
||||
host = types.SimpleNamespace(session_id="local-new", _session_db=db, _gateway_session_open=True)
|
||||
CLIInitMixin._new_session_id(host, None)
|
||||
assert [p for m, p in client.calls if m == "session.create"][-1]["row_cwd"] == ""
|
||||
monkeypatch.setattr(gss, "session_store", lambda: db)
|
||||
gss.create_titled_session("Fresh Named")
|
||||
assert [p for m, p in client.calls if m == "session.create"][-1]["row_cwd"] == ""
|
||||
monkeypatch.setenv("TERMINAL_ENV", "docker") # main stamps no host cwd for a remote backend
|
||||
db.create({"toolsets": ["file"]})
|
||||
assert [p for m, p in client.calls if m == "session.create"][-1]["row_cwd"] == ""
|
||||
@@ -18,6 +18,9 @@ class CanonicalCreateParams(ProfileParams):
|
||||
request_id: str | None = None
|
||||
source: Literal['cli', 'tui', 'gui', 'acp', 'tool', 'oneshot'] = 'cli'
|
||||
cwd: str | None = None
|
||||
# The sessions-row ``cwd`` when it differs from the execution ``cwd`` (the classic CLI records
|
||||
# main's stamp: the launch dir, ``""`` = none); ``cwd`` stays where the tools run.
|
||||
row_cwd: str | None = None
|
||||
model: str | None = None
|
||||
provider: str | None = None
|
||||
base_url: str | None = None
|
||||
|
||||
Reference in new issue
Block a user