fix(codex_app_server): retire the thread when the composed prompt changes mid-session

_ensure_codex_session records the developerInstructions composition it sent; when the
TUI/Desktop gateway mutates the live agent's prompt in place (/personality, prompt
mirror) the next turn closes the stale thread and starts one carrying the new prompt,
matching what the standard loop applies on its next API call. Sessions attached
without a recorded composition are kept. Docs sentence updated.
This commit is contained in:
teknium1
2026-09-19 01:23:16 -07:00
parent d046c4e408
commit ee69433c57
4 changed files with 40 additions and 6 deletions

View File

@@ -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),

View File

@@ -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

View File

@@ -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

View File

@@ -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.