fix(codex): a codex app-server thread started from scratch is seeded with the session's prior turns

A codex thread that codex hands back via thread/resume already holds the conversation, but a
thread started fresh did not: a session that ran on another provider before /model switched to
openai-codex, a stored thread codex could not resume, or a thread retired mid-session (prompt
composition change, wedged client) answered the first turn blind. The prior user/assistant text,
tool names and tool-result previews (most recent 32K chars) now ride once on
thread/start.developerInstructions after the prompt composition; thread/resume never carries them,
and the recorded composition stays the bare prompt so the seed cannot make the next turn retire the
thread.

Direction from #26081 (first-turn seeding of the Hermes transcript); redone on the extracted
agent/codex_runtime.py path with the system prompt sent once (#115759) instead of duplicated.

Completes #26035 / #74712 (closed by #115759 for the prompt half; this is the history half).
Co-authored-by: LeonSGP43 <154585401+LeonSGP43@users.noreply.github.com>
This commit is contained in:
teknium1
2026-09-19 19:53:25 -07:00
committed by Teknium
parent f9524d3f11
commit e84f0a1c5b
5 changed files with 170 additions and 8 deletions

View File

@@ -499,12 +499,13 @@ def _start_codex_thread(agent) -> str:
return agent._codex_session.ensure_started()
def _ensure_codex_session(agent) -> None:
def _ensure_codex_session(agent, messages: List[Dict[str, Any]] | None = None) -> None:
"""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.
Only the FIRST session of an AIAgent resumes the stored codex thread: a retired/recreated one keeps
today's fresh-thread behaviour and overwrites the binding once its turn is committed."""
today's fresh-thread behaviour and overwrites the binding once its turn is committed. ``messages`` is the
turn's transcript (current user row last); a thread started from scratch is seeded with the prior turns."""
developer_instructions = _codex_developer_instructions(agent)
if getattr(agent, "_codex_session", None) is not None:
# Only a session whose recorded composition differs is stale; one attached without a record is kept.
@@ -537,9 +538,13 @@ def _ensure_codex_session(agent) -> None:
# narrower item/started-only bridge from #38835.
# Hermes owns the prompt: the same composition the standard loop sends as its system message
# (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).
# thread as developerInstructions. A retired/recreated session re-sends the current composition.
# A thread started from scratch (no resumable codex thread) also receives the session's prior turns
# once, so a /model switch into codex or a retired thread does not start blind (#74712, #26035).
# The recorded composition stays the bare prompt: the seed must not make the next turn retire the thread.
agent._codex_session_prompt = developer_instructions
from agent.codex_runtime_history_seed import render_history_seed
history_seed = render_history_seed(messages) or None
# A named custom provider (``providers.<name>``) maps onto codex's own ``[model_providers.<name>]``
# table: send the stable id plus the active model and let codex resolve base_url/env_key itself, so
# Hermes' credential never enters the JSON-RPC payload (#75186). openai/openai-codex keep codex's defaults.
@@ -554,7 +559,7 @@ def _ensure_codex_session(agent) -> None:
on_event=make_codex_app_server_event_bridge(agent),
developer_instructions=developer_instructions or None,
model=getattr(agent, "model", None) if model_provider else None, model_provider=model_provider,
resume_thread_id=resume_thread_id,
resume_thread_id=resume_thread_id, history_seed=history_seed,
)
@@ -627,7 +632,7 @@ def run_codex_app_server_turn(agent, *, user_message: str, original_user_message
from agent.conversation_compression import _checkpoint_blocked
raise _checkpoint_blocked("codex_app_server owns the authoritative thread and compacts it "
"without a truthful pre-compaction transcript boundary")
_ensure_codex_session(agent)
_ensure_codex_session(agent, messages)
try:
_start_codex_thread(agent)
turn = agent._codex_session.run_turn(user_input=user_message)

View File

@@ -0,0 +1,65 @@
"""Render Hermes' prior transcript as a one-shot seed for a FRESH codex app-server thread.
A codex thread is the model-side continuity store, so a thread that codex hands back via
``thread/resume`` already knows the conversation. A thread started from scratch does not: a session
that ran on another provider before ``/model`` switched to openai-codex, a session whose stored thread
codex could not resume, or a thread retired mid-session (prompt composition change, wedged client)
would otherwise start blind (#26035, #74712; direction from #26081 by @LeonSGP43).
The seed rides on ``thread/start.developerInstructions`` after the prompt composition: codex inserts
that as the first developer message of every request in the thread, so the cap below bounds a
per-request cost and keeps the most recent turns (the ones the next answer depends on).
"""
from __future__ import annotations
from typing import Any, Dict, List
# Tail cap on the rendered history; ~8k tokens, resent by codex on every request of the thread.
MAX_HISTORY_SEED_CHARS = 32_000
_TOOL_RESULT_PREVIEW_CHARS = 400
_HEADER = ("Prior conversation from this Hermes session (the thread you are continuing was started fresh; "
"treat these turns as already having happened):")
def _text_of(content: Any) -> str:
if isinstance(content, str):
return content
if isinstance(content, list):
parts = [p if isinstance(p, str) else p.get("text", "") for p in content if isinstance(p, (str, dict))]
return "\n".join(p for p in parts if p)
return ""
def _render_row(msg: Dict[str, Any]) -> str:
role = msg.get("role")
text = _text_of(msg.get("content")).strip()
if role == "user":
return f"[USER]\n{text}" if text else ""
if role == "assistant":
calls = [c.get("function", {}).get("name") for c in msg.get("tool_calls") or [] if isinstance(c, dict)]
lines = [f"[ASSISTANT]\n{text}"] if text else []
if calls:
lines.append("[ASSISTANT called tools: " + ", ".join(c for c in calls if c) + "]")
return "\n".join(lines)
if role == "tool":
if len(text) > _TOOL_RESULT_PREVIEW_CHARS:
text = text[:_TOOL_RESULT_PREVIEW_CHARS] + " …"
return f"[TOOL RESULT]\n{text}" if text else ""
return "" # system rows are the prompt composition, already sent as developerInstructions
def render_history_seed(messages: List[Dict[str, Any]] | None) -> str:
"""Prior turns as one text block, newest last; empty when there is nothing before the current
user message. The trailing user row is the turn being submitted and is never included."""
rows = list(messages or [])
if rows and rows[-1].get("role") == "user":
rows = rows[:-1]
rendered = [r for r in (_render_row(m) for m in rows if isinstance(m, dict)) if r]
if not rendered:
return ""
body = "\n\n".join(rendered)
if len(body) > MAX_HISTORY_SEED_CHARS:
body = "[… earlier turns omitted …]\n\n" + body[-MAX_HISTORY_SEED_CHARS:]
return f"{_HEADER}\n\n{body}"

View File

@@ -217,6 +217,7 @@ class CodexAppServerSession:
client_factory: Optional[Callable[..., CodexAppServerClient]] = None,
model: Optional[str] = None, model_provider: Optional[str] = None,
developer_instructions: Optional[str] = None, resume_thread_id: Optional[str] = None,
history_seed: Optional[str] = None,
) -> None:
self._cwd = cwd or os.getcwd()
self._codex_bin = codex_bin
@@ -233,6 +234,9 @@ class CodexAppServerSession:
# inserts this as the first developer message of every model request. ``baseInstructions`` would
# REPLACE codex's base and ``instructions`` is accepted but ignored (verified against codex 0.147).
self._developer_instructions = developer_instructions
# Hermes' prior transcript, appended to developerInstructions ONLY when a thread is started from
# scratch: a resumed thread already holds the conversation (agent/codex_runtime_history_seed.py).
self._history_seed = history_seed
self._permission_profile = permission_profile or _HERMES_TO_CODEX_PERMISSION_PROFILE.get(
os.environ.get("HERMES_TERMINAL_SECURITY_MODE", "auto"), "workspace-write"
)
@@ -276,6 +280,9 @@ class CodexAppServerSession:
thread_id = self._resume_thread(wanted, params)
logger.info("codex app-server thread resumed: id=%s cwd=%s", thread_id[:8], self._cwd)
else:
if self._history_seed:
params["developerInstructions"] = "\n\n".join(
part for part in (params.get("developerInstructions"), self._history_seed) if part)
result = self._client.request("thread/start", params, timeout=15)
thread_id = _extract_thread_id(result)
if not thread_id:

View File

@@ -0,0 +1,85 @@
"""A codex app-server thread started from scratch is seeded with the session's prior turns (#26035, #74712).
Direction from #26081 (@LeonSGP43). A resumed codex thread already holds the conversation, so only a
fresh ``thread/start`` carries the seed, and the recorded prompt composition stays the bare prompt so
the seed never makes the next turn retire the thread.
"""
from types import SimpleNamespace
from agent import codex_runtime
from agent.transports import codex_app_server_session as sess_mod
class _FakeClient:
def __init__(self, **_kw):
self.requests = []
def close(self):
pass
def initialize(self, **_kw):
return {}
def request(self, method, params=None, timeout=None):
self.requests.append((method, params))
return {"thread": {"id": (params or {}).get("threadId", "fresh")}}
def _agent(**overrides):
base = dict(_codex_session=None, session_cwd="/tmp", tool_progress_callback=None,
_cached_system_prompt="SOUL: you are Hermes", ephemeral_system_prompt=None)
base.update(overrides)
return SimpleNamespace(**base)
_HISTORY = [
{"role": "system", "content": "SOUL: you are Hermes"},
{"role": "user", "content": "my dog is called Shadow"},
{"role": "assistant", "content": "Noted: Shadow.", "tool_calls": [{"function": {"name": "memory"}}]},
{"role": "tool", "content": "saved"},
{"role": "user", "content": "what is my dog called?"}, # the turn being submitted
]
def test_fresh_thread_is_seeded_with_prior_turns_but_not_the_current_one(monkeypatch):
client = _FakeClient()
monkeypatch.setattr(sess_mod, "CodexAppServerClient", lambda **kw: client)
agent = _agent()
codex_runtime._ensure_codex_session(agent, _HISTORY)
agent._codex_session.ensure_started()
(_, params), = [(m, p) for (m, p) in client.requests if m == "thread/start"]
instructions = params["developerInstructions"]
assert instructions.startswith("SOUL: you are Hermes")
assert "my dog is called Shadow" in instructions and "Noted: Shadow." in instructions
assert "called tools: memory" in instructions and "saved" in instructions
assert "what is my dog called?" not in instructions
assert instructions.count("SOUL: you are Hermes") == 1 # system rows are not re-rendered as history
# The seed is not part of the recorded composition: the next turn keeps the thread.
assert agent._codex_session_prompt == "SOUL: you are Hermes"
codex_runtime._ensure_codex_session(agent, _HISTORY + [{"role": "assistant", "content": "Shadow"}])
assert len([m for (m, _) in client.requests if m == "thread/start"]) == 1
def test_resumed_thread_gets_no_seed_but_a_failed_resume_fallback_does(monkeypatch):
"""thread/resume already holds the conversation; the fresh thread started after a failed resume does not."""
client = _FakeClient()
monkeypatch.setattr(sess_mod, "CodexAppServerClient", lambda **kw: client)
db = SimpleNamespace(get_session_model_config_value=lambda *_: "stored-thread", patch_session_model_config=lambda *_: None)
agent = _agent(_session_db=db, session_id="s1", _emit_diagnostic_status=lambda *_: None)
codex_runtime._ensure_codex_session(agent, _HISTORY)
codex_runtime._start_codex_thread(agent)
(_, resume_params), = [(m, p) for (m, p) in client.requests if m == "thread/resume"]
assert "my dog is called Shadow" not in resume_params.get("developerInstructions", "")
failing = _FakeClient()
failing.request = lambda method, params=None, timeout=None: (
(_ for _ in ()).throw(sess_mod.CodexAppServerError(code=-32602, message="unknown thread"))
if method == "thread/resume" else (failing.requests.append((method, params)) or {"thread": {"id": "fresh"}})
)
monkeypatch.setattr(sess_mod, "CodexAppServerClient", lambda **kw: failing)
agent = _agent(_session_db=db, session_id="s1", _emit_diagnostic_status=lambda *_: None)
codex_runtime._ensure_codex_session(agent, _HISTORY)
assert codex_runtime._start_codex_thread(agent) == "fresh"
(_, start_params), = [(m, p) for (m, p) in failing.requests if m == "thread/start"]
assert "my dog is called Shadow" in start_params["developerInstructions"]

View File

@@ -469,8 +469,8 @@ Known limitations:
- **`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.
- **`fallback_providers` fail over only on quota and rate-limit failures.** When a codex app-server turn fails with a billing / usage-limit / rate-limit error, Hermes switches to the configured [fallback provider](./fallback-providers.md) and retries the same turn on it; auth failures (`codex login` expired), turn timeouts and unknown-model errors do not fail over on this runtime and surface as the turn's error instead.
- **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.
- **The codex thread itself does survive a restart.** After each committed turn Hermes stores the codex thread id on the session row (`codex_thread_id` in the session's `model_config`, `hermes sessions` / `state.db`). The next agent built for that same Hermes session — a later `/api/sessions/{id}/chat` request, or the first turn after the API server or gateway restarts — issues `thread/resume` for the stored id before `turn/start`, so the model keeps its own memory of the earlier turns even though Hermes never replays its transcript. When codex cannot hand the thread back (its rollout was deleted, `CODEX_HOME` changed, the previous app-server was killed while still writing it), Hermes fails closed: it drops the stored id, starts a fresh thread and shows one line — `Codex thread could not be resumed; starting a new one.` — on the status rail of the surface you are on (CLI, TUI/Desktop, messaging gateway). A `/new` session never resumes an older thread.
- **Prior Hermes history is seeded only into a thread codex starts from scratch.** A codex thread that codex hands back via `thread/resume` already holds the conversation. When no resumable thread exists — the session ran on another provider before `/model` switched to openai-codex, codex could not resume the stored thread, or the running thread was retired — the new thread's `developerInstructions` carry Hermes' system prompt followed by the session's prior turns (user and assistant text, tool names, tool-result previews; the most recent ~32K characters). 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 plus that same history seed.
- **The codex thread itself does survive a restart.** After each committed turn Hermes stores the codex thread id on the session row (`codex_thread_id` in the session's `model_config`, `hermes sessions` / `state.db`). The next agent built for that same Hermes session — a later `/api/sessions/{id}/chat` request, or the first turn after the API server or gateway restarts — issues `thread/resume` for the stored id before `turn/start`, so the model keeps its own memory of the earlier turns (that is why no history seed is sent on resume). When codex cannot hand the thread back (its rollout was deleted, `CODEX_HOME` changed, the previous app-server was killed while still writing it), Hermes fails closed: it drops the stored id, starts a fresh thread and shows one line — `Codex thread could not be resumed; starting a new one.` — on the status rail of the surface you are on (CLI, TUI/Desktop, messaging gateway). A `/new` session never resumes an older thread.
- **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.