diff --git a/agent/codex_runtime.py b/agent/codex_runtime.py index 91be074afe..0c03bd6ef2 100644 --- a/agent/codex_runtime.py +++ b/agent/codex_runtime.py @@ -382,10 +382,25 @@ def _consume_user_interrupt(agent, active: bool = True) -> tuple[bool, Any]: return interrupted, message +def _codex_developer_instructions(agent) -> str: + """The prompt composition the standard loop sends as its system message (turn_context order).""" + developer_instructions = getattr(agent, "_cached_system_prompt", None) or "" + if getattr(agent, "ephemeral_system_prompt", None): + developer_instructions = (developer_instructions + "\n\n" + agent.ephemeral_system_prompt).strip() + return developer_instructions + + def _ensure_codex_session(agent) -> None: - """Lazily spawn one CodexAppServerSession per AIAgent (reused across turns, closed by the _cleanup hook).""" + """Lazily spawn one CodexAppServerSession per AIAgent (reused across turns, closed by the _cleanup hook). + A live session whose thread was started with a different prompt composition (TUI/Desktop ``/personality`` + or a prompt mirror mutate the agent in place) is retired first so the new thread carries the current one.""" + developer_instructions = _codex_developer_instructions(agent) if getattr(agent, "_codex_session", None) is not None: - return + # Only a session whose recorded composition differs is stale; one attached without a record is kept. + recorded = getattr(agent, "_codex_session_prompt", None) + if recorded is None or recorded == developer_instructions: + return + _close_codex_session(agent) from agent.runtime_cwd import resolve_agent_cwd from agent.transports.codex_app_server_session import CodexAppServerSession, _ServerRequestRouting # Approval callback: Hermes' standard prompt flow when a CLI thread installed one. @@ -410,9 +425,7 @@ def _ensure_codex_session(agent) -> None: # (cached per-session prompt + ephemeral additions such as channel overrides) rides along ONCE per # thread as developerInstructions. A retired/recreated session re-sends the current composition; # conversation history is still not projected into the codex thread (#74712, #26035). - developer_instructions = getattr(agent, "_cached_system_prompt", None) or "" - if getattr(agent, "ephemeral_system_prompt", None): - developer_instructions = (developer_instructions + "\n\n" + agent.ephemeral_system_prompt).strip() + agent._codex_session_prompt = developer_instructions agent._codex_session = CodexAppServerSession( cwd=getattr(agent, "session_cwd", None) or str(resolve_agent_cwd()), approval_callback=approval_callback, request_routing=_ServerRequestRouting(auto_approve_exec=auto_approve_requests, auto_approve_apply_patch=auto_approve_requests), diff --git a/tests/agent/test_codex_app_server_persist.py b/tests/agent/test_codex_app_server_persist.py index 46b167917c..df36c58d67 100644 --- a/tests/agent/test_codex_app_server_persist.py +++ b/tests/agent/test_codex_app_server_persist.py @@ -51,6 +51,7 @@ def _make_agent(session_db=None, session_id="sess-codex"): # Pre-seed the session so run_codex_app_server_turn skips the spawn block. agent._codex_session = MagicMock() agent._codex_session.run_turn.return_value = _make_turn() + agent._codex_session_prompt = None # seeded session: no recorded composition to compare agent.tool_progress_callback = None agent._iters_since_skill = 0 agent._skill_nudge_interval = 0 diff --git a/tests/agent/test_codex_runtime_prompt_handoff.py b/tests/agent/test_codex_runtime_prompt_handoff.py index c9fda680a3..2e67658b65 100644 --- a/tests/agent/test_codex_runtime_prompt_handoff.py +++ b/tests/agent/test_codex_runtime_prompt_handoff.py @@ -14,6 +14,10 @@ from agent.transports import codex_app_server_session as sess_mod class _FakeClient: def __init__(self, **_kw): self.requests = [] + self.closed = 0 + + def close(self): + self.closed += 1 def initialize(self, **_kw): return {} @@ -53,3 +57,19 @@ def test_runtime_omits_prompt_when_agent_has_none(monkeypatch): agent._codex_session.ensure_started() (_, params), = [(m, p) for (m, p) in client.requests if m == "thread/start"] assert "developerInstructions" not in params + + +def test_runtime_retires_thread_when_prompt_composition_changes(monkeypatch): + """TUI/Desktop ``/personality`` mutates the live agent's ephemeral prompt in place; the next turn must + retire the thread started with the old composition and start one carrying the new developerInstructions.""" + client = _FakeClient() + monkeypatch.setattr(sess_mod, "CodexAppServerClient", lambda **kw: client) + agent = _agent() + codex_runtime._ensure_codex_session(agent) + agent._codex_session.ensure_started() + agent.ephemeral_system_prompt = "Personality: pirate" + codex_runtime._ensure_codex_session(agent) + agent._codex_session.ensure_started() + starts = [p["developerInstructions"] for (m, p) in client.requests if m == "thread/start"] + assert starts == ["SOUL: you are Hermes\n\nAlways start with ZZZ", "SOUL: you are Hermes\n\nPersonality: pirate"] + assert client.closed == 1 # the stale thread's client was closed, not leaked diff --git a/website/docs/user-guide/features/codex-app-server-runtime.md b/website/docs/user-guide/features/codex-app-server-runtime.md index f0b433919a..bd794338cc 100644 --- a/website/docs/user-guide/features/codex-app-server-runtime.md +++ b/website/docs/user-guide/features/codex-app-server-runtime.md @@ -410,7 +410,7 @@ Known limitations: - **Hermes auth and codex auth are separate sessions.** You need both `codex login` AND `hermes auth add openai-codex` for the cleanest UX (the runtime uses codex's session for the LLM call). This is a deliberate design choice in Hermes' `_import_codex_cli_tokens` — Hermes won't share OAuth state with codex CLI to avoid clobbering each other on token refresh. - **`delegate_task`, `memory`, `session_search`, `todo` are unavailable on this runtime.** They need the running AIAgent context which a stateless MCP callback can't provide. Use `/codex-runtime auto` when you need these. - **No inline patch preview in approval prompts when codex doesn't track the changeset.** Codex's `fileChange` approval params don't always carry the changeset. Hermes caches the data from the corresponding `item/started` notification when possible, but if approval arrives before the item has streamed, the prompt falls back to whatever `reason` codex provides. -- **Conversation history is not projected into the codex thread.** The codex thread receives Hermes' system prompt when it starts plus each new user message; prior Hermes history (e.g. from a resumed session) is not replayed into it. Prompt changes made mid-session apply when the thread is next (re)created. +- **Conversation history is not projected into the codex thread.** The codex thread receives Hermes' system prompt when it starts plus each new user message; prior Hermes history (e.g. from a resumed session) is not replayed into it. When the composed prompt changes mid-session (for example `/personality` in the TUI or Desktop), the next turn retires the running thread and starts a new one carrying the updated prompt; that new thread does not inherit the retired thread's history. - **Sub-second cancellation isn't guaranteed.** Mid-stream interrupts (Ctrl+C while codex is responding) are sent via `turn/interrupt`, but if codex has already flushed the final message, you get the response anyway. If you find a bug, [open an issue](https://github.com/NousResearch/hermes-agent/issues) with the output of `hermes logs --since 5m`. Mention `codex-runtime` in the title so it's easy to triage.