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:
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user