test(codex_app_server): invariants for the Hermes prompt handoff; docs
Session: thread/start carries developerInstructions + personality "none", and omits developerInstructions when the prompt is blank (folds #72106's assertion into the rewritten cwd-only test). Runtime: _ensure_codex_session composes _cached_system_prompt + ephemeral_system_prompt exactly like turn_context and sends it once per thread across turns; no field when the agent has no prompt. Docs state that SOUL.md / system prompt / channel overrides now reach the model as developer instructions and that conversation history is still not projected into the codex thread. Contributor mapping for @Momentum96 (#27998 port).
This commit is contained in:
1
contributors/emails/wjsrjsdn12@gmail.com
Normal file
1
contributors/emails/wjsrjsdn12@gmail.com
Normal file
@@ -0,0 +1 @@
|
||||
Momentum96
|
||||
55
tests/agent/test_codex_runtime_prompt_handoff.py
Normal file
55
tests/agent/test_codex_runtime_prompt_handoff.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""The codex_app_server runtime hands Hermes' composed system prompt to the codex thread (#74712, #26035).
|
||||
|
||||
The standard loop sends ``_cached_system_prompt + ephemeral_system_prompt`` as its system message; the
|
||||
codex early-return used to send only cwd + raw user text, so SOUL.md / memory / channel_overrides were
|
||||
composed and then silently dropped.
|
||||
"""
|
||||
|
||||
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 initialize(self, **_kw):
|
||||
return {}
|
||||
|
||||
def request(self, method, params=None, timeout=None):
|
||||
self.requests.append((method, params))
|
||||
return {"thread": {"id": "t1"}}
|
||||
|
||||
|
||||
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="Always start with ZZZ")
|
||||
base.update(overrides)
|
||||
return SimpleNamespace(**base)
|
||||
|
||||
|
||||
def test_runtime_sends_composed_prompt_once_per_thread(monkeypatch):
|
||||
"""Composition mirrors turn_context (prompt + blank line + ephemeral); sent on thread/start
|
||||
exactly once even though _ensure_codex_session runs on every turn."""
|
||||
client = _FakeClient()
|
||||
monkeypatch.setattr(sess_mod, "CodexAppServerClient", lambda **kw: client)
|
||||
agent = _agent()
|
||||
for _ in range(3): # three turns reuse one session
|
||||
codex_runtime._ensure_codex_session(agent)
|
||||
agent._codex_session.ensure_started()
|
||||
starts = [p for (m, p) in client.requests if m == "thread/start"]
|
||||
assert len(starts) == 1
|
||||
assert starts[0]["developerInstructions"] == "SOUL: you are Hermes\n\nAlways start with ZZZ"
|
||||
|
||||
|
||||
def test_runtime_omits_prompt_when_agent_has_none(monkeypatch):
|
||||
"""No cached prompt and no ephemeral additions → no developerInstructions field at all."""
|
||||
client = _FakeClient()
|
||||
monkeypatch.setattr(sess_mod, "CodexAppServerClient", lambda **kw: client)
|
||||
agent = _agent(_cached_system_prompt=None, ephemeral_system_prompt=None)
|
||||
codex_runtime._ensure_codex_session(agent)
|
||||
agent._codex_session.ensure_started()
|
||||
(_, params), = [(m, p) for (m, p) in client.requests if m == "thread/start"]
|
||||
assert "developerInstructions" not in params
|
||||
@@ -164,17 +164,23 @@ class TestLifecycle:
|
||||
method_calls = [m for (m, _) in client.requests if m == "thread/start"]
|
||||
assert len(method_calls) == 1
|
||||
|
||||
def test_thread_start_passes_cwd_only(self):
|
||||
"""thread/start carries cwd. We intentionally do NOT pass `permissions`
|
||||
on this codex version (experimentalApi-gated + requires matching
|
||||
config.toml [permissions] table). Letting codex use its default
|
||||
(read-only unless user configures otherwise) is the documented path."""
|
||||
def test_thread_start_carries_hermes_prompt_and_disables_codex_personality(self):
|
||||
"""thread/start carries cwd, Hermes' composed prompt as developerInstructions and
|
||||
personality "none" (#74712, #72104, #26035). We intentionally do NOT pass `permissions`
|
||||
(experimentalApi-gated + requires a matching config.toml [permissions] table)."""
|
||||
client = FakeClient()
|
||||
s = make_session(client, permission_profile="workspace-write")
|
||||
s = make_session(client, permission_profile="workspace-write", developer_instructions="SOUL: be terse")
|
||||
s.ensure_started()
|
||||
method, params = next(r for r in client.requests if r[0] == "thread/start")
|
||||
assert params["cwd"] == "/tmp"
|
||||
assert "permissions" not in params # see session.ensure_started() comment
|
||||
assert params == {"cwd": "/tmp", "developerInstructions": "SOUL: be terse", "personality": "none"}
|
||||
|
||||
def test_thread_start_omits_developer_instructions_when_prompt_empty(self):
|
||||
"""No prompt (or a blank one) never sends an empty developerInstructions field."""
|
||||
client = FakeClient()
|
||||
make_session(client, developer_instructions=" ").ensure_started()
|
||||
method, params = next(r for r in client.requests if r[0] == "thread/start")
|
||||
assert "developerInstructions" not in params
|
||||
assert params["personality"] == "none"
|
||||
|
||||
def test_close_idempotent(self):
|
||||
client = FakeClient()
|
||||
|
||||
@@ -20,6 +20,7 @@ Not using OpenAI Codex? `hermes setup --portal` configures a non-Codex backend w
|
||||
- **Native Codex plugins** — Linear, GitHub, Gmail, Calendar, Canva, etc. — installed via `codex plugin` are auto-migrated and active in your Hermes session.
|
||||
- **Hermes' richer tools come along** — web_search, web_extract, browser automation, vision, image generation, skills, and TTS work via an MCP callback. Codex calls back into Hermes for tools it doesn't have built in.
|
||||
- **Memory and skill nudges keep working** — Codex's events are projected into Hermes' message shape so the self-improvement loop sees a normal-looking transcript.
|
||||
- **Your Hermes persona rides along** — the composed system prompt (SOUL.md, MEMORY.md/USER.md, per-channel `system_prompt` overrides) is sent to the codex thread once as developer instructions when the thread starts, and Codex's built-in personality is disabled so it cannot compete with yours.
|
||||
|
||||
## What tools the model actually has
|
||||
|
||||
@@ -125,6 +126,7 @@ The kanban tools are gated by `HERMES_KANBAN_TASK` env var the dispatcher sets
|
||||
| Native Codex plugins (Linear, GitHub, etc.) | — | yes (auto-migrated) |
|
||||
| User MCP servers | yes | yes (auto-migrated to codex) |
|
||||
| Memory + skill review (background) | yes | yes (via item projection) |
|
||||
| System prompt / SOUL.md / channel `system_prompt` overrides | yes | yes (sent once as developer instructions on thread start) |
|
||||
| Multi-turn conversations | yes | yes |
|
||||
| `/goal` (Ralph loop) | yes | yes |
|
||||
| Kanban worker dispatch | yes | yes (via callback) |
|
||||
@@ -408,6 +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.
|
||||
- **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