From e3cfb5c571a15d8e8d27a8cbedcd80a6d8f20dfb Mon Sep 17 00:00:00 2001 From: pufit Date: Fri, 10 Jul 2026 16:23:59 -0400 Subject: [PATCH 1/8] Add OpenAI Codex agent backend behind a multi-backend engine seam --- docs/architecture.md | 15 + docs/config.md | 42 + docs/plans/codex-backend.md | 701 +++++++ docs/sdk-sessions.md | 10 + nerve/agent/backends/__init__.py | 100 + nerve/agent/backends/base.py | 175 ++ nerve/agent/backends/claude.py | 1085 +++++++++++ nerve/agent/backends/codex/__init__.py | 19 + nerve/agent/backends/codex/appserver.py | 383 ++++ nerve/agent/backends/codex/backend.py | 884 +++++++++ nerve/agent/backends/codex/pricing.py | 63 + nerve/agent/backends/events.py | 182 ++ nerve/agent/backends/images.py | 135 ++ nerve/agent/engine.py | 1933 ++++++------------- nerve/agent/interactive.py | 220 ++- nerve/agent/prompts.py | 21 +- nerve/agent/tools/claude_sdk_adapter.py | 12 +- nerve/agent/tools/handlers/__init__.py | 2 + nerve/agent/tools/handlers/wakeups.py | 98 + nerve/config.py | 172 ++ nerve/db/migrations/v038_session_backend.py | 26 + nerve/db/sessions.py | 2 +- nerve/gateway/auth.py | 42 +- nerve/mcp_server/auth.py | 43 +- nerve/mcp_server/http.py | 72 +- scripts/codex_smoke.py | 146 ++ tests/fixtures/codex_schema_meta.json | 7 + tests/fixtures/fake_codex_appserver.py | 278 +++ tests/test_autonomous_turns.py | 87 +- tests/test_cache_policy.py | 19 +- tests/test_codex_appserver.py | 381 ++++ tests/test_codex_protocol.py | 166 ++ tests/test_engine.py | 250 ++- tests/test_engine_backend_selection.py | 186 ++ tests/test_interactive.py | 20 +- tests/test_mcp_session_binding.py | 148 ++ tests/test_workflow_progress.py | 12 +- web/src/api/websocket.ts | 2 +- web/src/components/Chat/ApprovalCard.tsx | 101 + web/src/pages/ChatPage.tsx | 3 + web/src/stores/chatStore.ts | 2 +- 41 files changed, 6632 insertions(+), 1613 deletions(-) create mode 100644 docs/plans/codex-backend.md create mode 100644 nerve/agent/backends/__init__.py create mode 100644 nerve/agent/backends/base.py create mode 100644 nerve/agent/backends/claude.py create mode 100644 nerve/agent/backends/codex/__init__.py create mode 100644 nerve/agent/backends/codex/appserver.py create mode 100644 nerve/agent/backends/codex/backend.py create mode 100644 nerve/agent/backends/codex/pricing.py create mode 100644 nerve/agent/backends/events.py create mode 100644 nerve/agent/backends/images.py create mode 100644 nerve/agent/tools/handlers/wakeups.py create mode 100644 nerve/db/migrations/v038_session_backend.py create mode 100755 scripts/codex_smoke.py create mode 100644 tests/fixtures/codex_schema_meta.json create mode 100755 tests/fixtures/fake_codex_appserver.py create mode 100644 tests/test_codex_appserver.py create mode 100644 tests/test_codex_protocol.py create mode 100644 tests/test_engine_backend_selection.py create mode 100644 tests/test_mcp_session_binding.py create mode 100644 web/src/components/Chat/ApprovalCard.tsx diff --git a/docs/architecture.md b/docs/architecture.md index 78e4a87..7662cf4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -151,6 +151,21 @@ Markdown + SQLite task system: - SQLite index for queries - Escalation reminders (soft → medium → urgent) +### Agent Backends + +The engine is backend-agnostic: per-session clients implement the +`AgentBackend`/`AgentClient` protocols (`nerve/agent/backends/`) and +translate their native streams into normalized events +(`backends/events.py`) — the only vocabulary the engine consumes. +`backends/claude.py` wraps the Claude Agent SDK (options, hooks, +`can_use_tool`, idle-stream drains, hardened disconnect); +`backends/codex/` speaks JSON-RPC to a per-session `codex app-server` +subprocess (threads/turns, approval server-requests routed to the +interaction hub, per-turn pricing). Backend choice is sticky per session +(`sessions.backend`); capabilities (cumulative vs per-turn cost, +idle-stream support, cache-TTL policy) gate engine behavior — never +`isinstance`. Details: `docs/plans/codex-backend.md`. + ## Data Flow ### User Message (Telegram) diff --git a/docs/config.md b/docs/config.md index 90f9160..315aa81 100644 --- a/docs/config.md +++ b/docs/config.md @@ -51,6 +51,48 @@ from any working directory: **Note:** The engine uses a `can_use_tool` callback (not `bypassPermissions`) so that interactive tools (`AskUserQuestion`, `ExitPlanMode`, `EnterPlanMode`) can pause mid-turn for user input. All other tools are auto-approved. See [sdk-sessions.md](sdk-sessions.md#permissions--interactive-tools) for details. +## Agent Backends (claude / codex) + +Nerve can run sessions on two agent runtimes. The backend is selected per +NEW session and is **sticky**: it's stamped into `sessions.backend` at first +client build and always wins over config afterwards, so flipping the +defaults never crosses an existing conversation (or its wakeups) onto a +runtime that can't resume it. See `docs/plans/codex-backend.md`. + +```yaml +agent: + backend: claude # claude | codex — new interactive sessions + cron_backend: null # null → backend; new cron/hook sessions only + +codex: # active when a codex backend is selected + bin_path: codex # codex-cli >= 0.144 on PATH + home_dir: ~/.nerve/codex # isolated CODEX_HOME (auth, config, sessions) + model: gpt-5.6-codex + cron_model: null # null → model + auth: chatgpt # chatgpt | api_key + api_key: null # config.local.yaml; or api_key_env: OPENAI_API_KEY + sandbox: danger-full-access # read-only | workspace-write | danger-full-access + approval_policy: never # never | on-request | untrusted + web_search: true + tool_timeout_sec: 3600 # nerve MCP calls may block on ask_user + turn_idle_timeout_seconds: null # null → agent.cli_idle_timeout_seconds + pricing: # $/1M tokens — cost is None for unlisted models + gpt-5.6-codex: {input: 5.0, cached_input: 0.5, output: 30.0} + extra_config: {} # arbitrary codex -c key=value passthrough +``` + +Setup for `auth: chatgpt`: run `CODEX_HOME=~/.nerve/codex codex login` once, +then `scripts/codex_smoke.py` to verify end-to-end before flipping any +backend default. Codex sessions reach nerve tools through the gateway's +`/mcp/v1` endpoint with a session-bound token (minted automatically); +`mcp_endpoint.enabled` must stay on. + +Notes: prompt-cache TTL policy, Claude Code plugins, Langfuse tracing and +PDF inputs are claude-only (see the plan's §14 non-parity list). With the +default `approval_policy: never` + full-access sandbox, codex sessions +behave like claude's auto-approved tools; tightening the policy surfaces +Approve/Decline cards in the web UI. + ## Gateway | Key | Type | Default | Description | diff --git a/docs/plans/codex-backend.md b/docs/plans/codex-backend.md new file mode 100644 index 0000000..efcd40d --- /dev/null +++ b/docs/plans/codex-backend.md @@ -0,0 +1,701 @@ +# Codex Backend — Multi-Backend Agent Engine + +**Status:** implementation plan v2 (2026-07-10; v1 revised after adversarial review — all +blockers/majors incorporated, see §16) +**Branch:** `pufit/codex-backend` +**Goal:** run Nerve sessions on OpenAI Codex (GPT‑5.6 family) with **full parity** to the +Claude path — interactive web sessions, Telegram, cron, wakeups, forks, resume — behind a +clean backend abstraction. Claude remains the default; the backend is selected by config +and **sticky per session**. + +--- + +## 0. Ground truth (verified 2026-07-10 against codex-cli 0.144.1 exported schema) + +### Nerve today + +- `AgentEngine` (nerve/agent/engine.py, ~3900 lines) is hardwired to + `claude_agent_sdk.ClaudeSDKClient`. One persistent client (= one CLI subprocess) per + session; `client.query()` per turn; `client.receive_response()` streamed and translated + in `_process_sdk_message()` into broadcaster events + `_TurnState` accumulation. +- Session persistence: `sessions.sdk_session_id` + SDK `resume` / `fork_session`; + claude resume targets validated by `_sdk_resume_file_exists` (engine.py:1539) — a + Claude-CLI-specific jsonl check. +- Interactive pausing: `can_use_tool` callback → `InteractiveToolHandler` + (AskUserQuestion / EnterPlanMode / ExitPlanMode pause mid-turn; everything else + auto-approved). Non-web sources auto-deny interactive tools. The handler currently + imports `claude_agent_sdk.types.PermissionResult*`. +- Hooks: PreToolUse (file snapshots for diff view, image validation, background-agent + permission parity), PostToolUse (ScheduleWakeup capture → nerve's wakeup sweep; + wakeups fire back into the *same session* via `engine.run(..., source="wakeup")`). +- Tools: runtime-agnostic `ToolRegistry`/`ToolContext`/`ToolResult` with two adapters + in-tree: in-process SDK MCP (claude_sdk_adapter.py) and the **Streamable HTTP MCP + endpoint** (nerve/mcp_server/, `/mcp/v1`, JWT via `gateway.auth.decode_token`, + per-request `ctx_resolver`, `SatelliteSessionResolver` for external clients). +- Cost: SDK reports *cumulative* `total_cost_usd`; `_finalize_turn` persists a + high-water mark (`meta["_sdk_cumulative_cost"]`) and `compute_turn_cost` diffs it; + `estimate_turn_cost` falls back to a DEFAULT_PRICING table when the model is unknown. +- Usage dict keys are Anthropic-shaped everywhere downstream: `_finalize_turn` reads + `input_tokens` / `cache_read_input_tokens` / `cache_creation_input_tokens`, cache-ttl + split reads `cache_creation.ephemeral_*`, and the web UI reads the same keys off the + `done` event. +- Autonomous CLI turns: `_idle_stream_watcher` + `_drain_pending_messages` probe the + SDK's buffered stream non-blockingly, park with timeouts, and frame turns off + `system/init` messages — Claude-specific capability. + +### Codex (codex-cli 0.144.1; `codex app-server`; schema exported via +`codex app-server generate-json-schema`) + +- Long-lived subprocess, bidirectional **JSON-RPC 2.0 over stdio** (JSONL). +- Client→server (subset we use): `initialize` (clientInfo + capabilities incl. + `optOutNotificationMethods`), `thread/start`, `thread/resume`, `thread/fork`, + `turn/start`, `turn/interrupt` (`{threadId, turnId}`), `model/list`, `account/read`, + `account/login/start {type: apiKey}`. +- `ThreadStartParams`: `model`, `cwd`, `sandbox` (SandboxMode: + `read-only|workspace-write|danger-full-access`), `approvalPolicy` (AskForApproval: + `untrusted|on-request|never` — **no `on-failure` in v2**), `baseInstructions`, + `developerInstructions` (also available on resume/fork — needed for client rebuilds), + `config` (free per-thread config-override dict, `additionalProperties: true`; + `mcp_servers`, `project_doc_max_bytes` are valid keys; overrides are ignored for + already-running threads), `ephemeral`, `personality`, `modelProvider`. + `thread/start` response carries `thread.id` immediately. +- `TurnStartParams`: `input: [UserInput]` — text `{type:"text",text}`, images ONLY as + `{type:"image", url}` (data: URLs ok) or `{type:"localImage", path}`; **no PDF input + type**. Per-turn overrides: `model`, `effort` (free string, model-dependent), `cwd`, + `approvalPolicy`, `sandboxPolicy` (SandboxPolicy object — different type from + thread-level SandboxMode), `summary`, `outputSchema`, `personality`. +- Server→client **requests** (turn pauses until the client responds): + `item/commandExecution/requestApproval`, `item/fileChange/requestApproval` + (`{itemId, reason?, grantRoot?}` — no diff payload; correlate via itemId), + `item/permissions/requestApproval`, `item/tool/requestUserInput`, + `mcpServer/elicitation/request`. Decisions include `accept|acceptForSession|decline|cancel`. +- Server→client **notifications** (subset): `thread/started`, `turn/started`, + `item/started`, `item/completed`, `item/agentMessage/delta`, + `item/reasoning/textDelta`, `item/reasoning/summaryTextDelta`, + `item/commandExecution/outputDelta`, `item/fileChange/patchUpdated`, + `item/mcpToolCall/progress`, `item/plan/delta`, **`thread/tokenUsage/updated`** + (`{threadId, turnId, tokenUsage: {last, total, modelContextWindow}}` — THE usage + source), `turn/completed` (`Turn = {id, items, status, startedAt, completedAt, + durationMs, error}` — **no usage field**; `status ∈ + completed|interrupted|failed|inProgress`; `error: TurnError` when failed), generic + `error {error, willRetry}`, `account/rateLimits/updated`, `thread/compacted`, + `model/rerouted`. **There is no `turn/failed` method.** +- Item payloads: `CommandExecutionThreadItem = {command, cwd, commandActions, + aggregatedOutput, exitCode, status...}` (no `description` field); + `FileChangeThreadItem.changes = [{path, kind, diff}]` — an **array** of per-file + changes with unified diffs. +- Token usage: `TokenUsageBreakdown` = input, cached_input, output, reasoning_output, + total. **No cost-in-USD anywhere** → we price it ourselves. +- MCP server config (`RawMcpServerConfig`, confirmed in binary): stdio + (`command/args/env`) and streamable HTTP (`url`, `http_headers`, + `bearer_token_env_var`). MCP tool identifiers keep the `mcp__server__tool` naming, so + existing prompt references to `mcp__nerve__*` hold under codex. +- Official Python SDK (`openai-codex` 0.1.0b2) is a thin wrapper over this protocol, + but dispatches server-requests **synchronously on the reader thread** (a blocking + approval handler stalls all routing incl. the `turn/interrupt` response → deadlock) + and `AsyncCodexClient` accepts no `approval_handler` at all. Interactive pausing is + impossible on it → **we implement our own asyncio-native app-server client** + (~400 lines against a schema-exported protocol; zero new runtime deps). + +--- + +## 1. Design principles + +1. **One seam, two implementations.** Engine keeps everything backend-agnostic + (session lifecycle, DB persistence, broadcasting, turn state, memorization, cron, + locks, idle sweep, turn framing for autonomous drains). Backends own: process/client + lifecycle, option building, native event → normalized event translation, + permission/approval wiring, resume-target validation, usage/cost normalization. +2. **Normalize at the boundary.** Engine consumes only nerve-owned event types. + No `claude_agent_sdk` or codex types cross the seam — including + `InteractiveToolHandler`, whose claude-typed `can_use_tool` adapter moves into the + claude backend (§7). +3. **Capabilities, not isinstance.** Backend differences the engine must act on + (cumulative vs per-turn cost, idle-stream draining, cache-ttl policy, resume + validation, context-window reporting) are declared as flags/methods on the backend. +4. **Sticky backends.** A session's backend is chosen once (at first client build), + persisted in `sessions.backend`, and **always wins over config** afterwards. + Wakeup/internal turns on an existing session can never cross backends. +5. **Zero behavior change for Claude.** Default config keeps `backend: claude`; the + refactor is a pure extraction. The existing suite stays green (files that patch + moved helpers are updated mechanically — enumerated in §12). +6. **Defensive protocol handling.** Unknown notifications → debug log; unknown + server-requests → safe decline/empty response; missing fields never crash a turn. + +--- + +## 2. Package layout + +``` +nerve/agent/backends/ + __init__.py # re-exports + get_backend() registry + base.py # AgentBackend/AgentClient protocols, SessionSpec/TurnInput/BackendCapabilities + events.py # normalized AgentEvent union + NormalizedUsage + claude.py # ClaudeBackend/ClaudeClient — extraction of today's code + codex/ + __init__.py + appserver.py # CodexAppServerClient: asyncio JSON-RPC stdio transport + backend.py # CodexBackend/CodexClient: threads, turns, event mapping + protocol.py # method names, param builders, notification→event mapping helpers + pricing.py # usage → USD from config-driven price table +``` + +## 3. The seam (base.py) + +```python +@dataclass +class SessionSpec: + session_id: str + source: str # web | telegram | cron | wakeup | hook + model: str | None # explicit override; None → backend default for source + effort: str # nerve vocabulary (backend maps via effort_map) + system_prompt: str # fully rendered nerve system prompt + cwd: str + resume_native_id: str | None # backend-native session/thread id + fork: bool # resume_native_id is a parent to fork from + interactive: InteractionHub # backend-neutral pause/approve machinery (§7) + snapshot: SnapshotFn # async (session_id, path, content|None) -> None + record_wakeup: WakeupFn # async (session_id, tool_input) -> None + cache_ttl: str # claude-only; codex ignores + max_turns: int + idle_timeout: float # per-message hang detection (engine-resolved, §10) + extra: dict # escape hatch (betas, thinking, plugins...) + +@dataclass +class TurnInput: + text: str + images: list[dict] | None # engine-normalized: {media_type, data(b64)} | {path} + # claude: native blocks; codex: b64→data: URL / + # path→localImage. PDFs: claude native; codex — + # backend returns UnsupportedInput → engine appends + # a clear inline note instead (no silent drop). + +class AgentClient(Protocol): + @property + def native_session_id(self) -> str | None: ... + # codex: set at thread/start (immediately); claude: first stream message. + # Engine's /stop-mid-turn persistence path reads THIS (replaces the old + # early-capture from raw SDK messages). + async def connect(self) -> None: ... + async def start_turn(self, turn: TurnInput) -> None: ... + def receive_turn(self) -> AsyncIterator[AgentEvent]: ... + # yields until TurnCompleted; MUST also terminate on interrupted turns + async def interrupt(self) -> None: ... + async def disconnect(self) -> None: ... # owns ALL process-teardown internals + # (claude: today's _safe_disconnect body) + def is_alive(self) -> bool: ... + # -- autonomous/idle stream (capability-gated; codex: try→None, receive→raises) -- + def try_receive_idle_event(self) -> AgentEvent | None: ... # never parks + async def receive_idle_event(self, timeout: float) -> AgentEvent | None: ... + def buffer_used(self) -> int: ... # 0 when N/A + +@dataclass(frozen=True) +class BackendCapabilities: + cost_is_cumulative: bool # claude True (engine diffs); codex False (per-turn, precomputed) + supports_idle_stream: bool # claude True; codex False + supports_cache_ttl: bool # claude True; codex False + interactive_builtins: bool # claude True (AskUserQuestion/plan mode) + reports_context_window: bool # codex True (thread/tokenUsage/updated) + +class AgentBackend(Protocol): + name: str # "claude" | "codex" + capabilities: BackendCapabilities + def default_model(self, source: str) -> str: ... + async def create_client(self, spec: SessionSpec) -> AgentClient: ... + def validate_resume_target(self, native_id: str, cwd: str) -> bool: ... + # claude: today's _sdk_resume_file_exists jsonl check. + # codex: returns True (cheap check impossible); stale ids are handled by + # create_client falling back: thread/resume error → clear id → thread/start + # fresh (and the engine is told the id was dropped so it clears the DB column). + def excluded_tools(self) -> set[str]: ... # nerve-registry tools NOT to expose +``` + +**Backend resolution (engine):** +1. `sessions.backend` column set → that backend, always (wakeup/internal/cron-fired + turns on existing sessions inherit it; a missing backend implementation at runtime + is a hard error telling the operator to restore config). +2. Unset (new session) → `agent.cron_backend` for sources `cron|hook`, else + `agent.backend`; stamped into `sessions.backend` at first client build. `wakeup` is + NOT a cron source (matches today's `_CRAN_EFFORT_SOURCES` treatment — wakeups always + land on existing sessions anyway). +3. Session-metadata `backend_override` (set at creation time by API/UI later) wins over + config for new sessions — the A/B hook. +4. Ollama guard: `ollama.enabled` + non-claude model string routes through the claude + CLI proxy **today**; that combination therefore forces the claude backend. A codex + backend with an ollama model is a config-validation error. + +**Model resolution:** explicit `model=` argument wins; else `backend.default_model +(source)` (claude: `agent.model`/`agent.cron_model`; codex: `codex.model`/ +`codex.cron_model`). `run_cron`/`run_persistent_cron`/`run_hook` **stop pre-resolving** +`self.config.agent.cron_model` at their call sites (engine.py:3657/3695/3724) and pass +the job's explicit model or None; same fix in `_get_or_create_client`'s +`requested_model` and the langfuse tag default (engine.py:2973). + +## 4. Normalized events (events.py) + +```python +@dataclass +class NormalizedUsage: + input_tokens: int + output_tokens: int + cache_read_tokens: int + cache_creation_tokens: int # codex: 0 + raw: dict # backend-native payload + + def to_anthropic_shape(self) -> dict: + # THE usage-dict contract: engine persists/broadcasts THIS shape, so + # _finalize_turn, extract_cache_ttl_split (absent keys → zeros), and the + # web UI's done-event reader keep working for both backends: + # {input_tokens, output_tokens, cache_read_input_tokens, + # cache_creation_input_tokens, ...raw passthrough for claude} + # For claude the raw dict IS already this shape and is passed through + # untouched (preserving cache_creation.ephemeral_* for the TTL split). + +AgentEvent = ( + TextDelta(text, parent_tool_use_id=None) + | ThinkingDelta(text, parent_tool_use_id=None) + | ToolUse(tool_use_id, name, input, parent_tool_use_id=None) + | ToolResult(tool_use_id, content, is_error, parent_tool_use_id=None) + | SubagentStarted(tool_use_id, subagent_type, description, model) # claude-only + | ModelObserved(model) # feeds _track_serving_model / st.last_model; + # claude: AssistantMessage.model when + # parent_tool_use_id is None; codex: resolved + # thread model at turn start + model/rerouted + | SystemEvent(subtype, data) # claude system messages (init/task chips/workflow), + # codex plan deltas, rate-limit updates + | TurnCompleted( + native_session_id, model, + usage: NormalizedUsage | None, + total_cost_usd: float | None, # claude: cumulative; codex: THIS turn, precomputed + duration_ms, duration_api_ms, num_turns, + context_window: int | None, # codex: modelContextWindow; claude: None + status: "completed"|"interrupted"|"failed", + error: str | None) +) +``` + +Engine's `_process_sdk_message` becomes `_process_agent_event(session_id, event, st)`: +same accumulation/broadcast body re-keyed on event types. One SDK message may translate +to N events (multi-block AssistantMessage). Claude translation lives in +`claude.py::translate_message()` and is unit-tested for parity (§12). + +## 5. ClaudeBackend (claude.py) — pure extraction, complete inventory + +Moves from engine.py / interactive.py with behavior unchanged: + +- `_build_options` (system-prompt file spill, thinking/effort/betas/extra_args/ + disallowed_tools/env/plugins/mcp_servers), `_parse_thinking_config`, + `_effective_effort`, `_model_family`, `_model_supports_legacy_enabled_thinking`, + `_build_env` (Anthropic/Bedrock/proxy + cache-ttl env), `_build_hooks` (snapshot, + image validation incl. `_validate_image_file`/`_validate_image_data`, wakeup capture + → `spec.record_wakeup`, background-permission hook), the CLI stderr filter, + **`_sdk_resume_file_exists`** (→ `validate_resume_target`), **`_safe_disconnect`** + (→ `ClaudeClient.disconnect()`, keeping the transport/process/task-group teardown + internals), `_is_client_dead`, `_sdk_buffer_used`, `_sdk_message_stream` (→ the + idle-event methods; parsing via the SDK message parser stays inside the backend), + and the `can_use_tool` adapter + `PermissionResult*` mapping from interactive.py (§7). +- `ClaudeClient.start_turn` keeps the image/document async-generator query path + (engine.py:2993) — documents (PDFs) remain claude-native. +- In-process MCP server (claude_sdk_adapter) unchanged, now built with the backend's + exclusion set applied. + +Engine keeps: locks/semaphore, `_TurnState`, broadcasting, DB writes, title generation +(Anthropic direct client — see §10 note), memorization, cron/wakeup services, the +idle-client sweep (calls `client.disconnect()`), `_drain_pending_messages`' **turn +framing** (re-keyed on `SystemEvent("init")` / `TurnCompleted` events; the +probe/park loop uses `try_receive_idle_event` / `receive_idle_event(timeout)`), the +generic per-message timeout wrapper, and cost bookkeeping (§9). + +## 6. CodexAppServerClient (codex/appserver.py) + +Asyncio-native JSON-RPC client; the only transport-aware code: + +- Spawn `codex app-server` via `asyncio.create_subprocess_exec` (stdin/stdout PIPE, + stderr → severity-filtered logger). Env: isolated `CODEX_HOME` (§10), auth env, + `NERVE_MCP_TOKEN` (§8). +- `initialize` with `clientInfo={name:"nerve",...}`; `optOutNotificationMethods` for + surfaces we never consume (realtime/*, fuzzyFileSearch/*). +- Single reader task; dispatch: responses → pending `Future`s; notifications → + per-turn queue + global queue; **server requests** → `asyncio.create_task(handler)`, + reply `{id, result}` on resolution — approvals can await user input indefinitely + without stalling the stream (this is the property the official SDK lacks). + Unknown server-request methods → safe default (decline-shaped for `*Approval`, + `{}` otherwise) + warning. +- `request(method, params, timeout)` / write lock on stdin; EOF → fail all pending + with `CodexTransportError`; `is_alive()` = process poll + reader liveness. Engine's + existing hung-client retry path is reused because `receive_turn` surfaces the same + timeout/error shapes. +- Dicts in, dicts out; no pydantic/codegen dependency. Method names + param builders + centralized in `protocol.py`. A schema version marker + (`tests/fixtures/codex_schema_meta.json`) records what we verified against. + +## 7. CodexBackend / CodexClient (codex/backend.py) + interaction seam + +### Interaction seam (applies to both backends) + +`InteractiveToolHandler` splits: +- **`InteractionHub`** (interactive.py, backend-neutral): pending/resolve/deny/cancel + machinery, WebSocket `interaction` broadcast, `session_awaiting_input`, timeout — + today's code minus claude types, plus `request_approval(kind, payload) -> + ApprovalDecision` reusing the same machinery with new interaction types + `command_approval` / `file_approval`. +- **Claude adapter** (backends/claude.py): `can_use_tool` callback translating hub + decisions into `PermissionResultAllow/Deny` — the only place claude permission types + live. `tests/test_interactive.py`'s type imports update accordingly. +- **Frontend** (in scope — full parity includes tightened-sandbox operation): + extend the interaction type union (`web/src/stores/chatStore.ts`), add a generic + `ApprovalBlock` card (command / file-change variants; file approvals render the + correlated `item/started` payload the backend attaches — the raw request carries no + diff), route answers through the existing `answer_interaction` WS message. Rebuild + the frontend (`npm run build`) as the nerve-dev skill prescribes. + +### Session → thread lifecycle + +- `create_client(spec)`: spawn app-server (one process per nerve session — mirrors the + claude process model so idle sweep / kill / rebuild semantics carry over; RSS cost + measured in the smoke test and recorded here before any wide flip), `initialize`, + then `thread/start` | `thread/resume` | `thread/fork`. + **Resume-miss recovery:** `thread/resume`/`thread/fork` JSON-RPC error → log, report + `resume_dropped` to the engine (clears `sessions.sdk_session_id`), `thread/start` + fresh. Never brick a session on a wiped `~/.nerve/codex/sessions`. +- Thread params: `cwd`, `model` (resolved per §3), `developerInstructions = + spec.system_prompt + backend notes` (see §9-prompt), `sandbox` + `approvalPolicy` + from config (defaults `danger-full-access` + `never` = parity with claude + auto-approve), `config` override dict: `mcp_servers` (nerve + translated external + servers, §8), `project_doc_max_bytes: 0`, web-search toggle. +- `native_session_id` = thread id (known at `thread/start` — available to the engine's + cancel-persistence path immediately). + +### Turns + +- `start_turn`: `turn/start {threadId, input:[text + images (data:-URL / localImage)], + effort: effort_map[spec.effort], model when overridden}`; capture `turn.id` + (interrupt needs it), subscribe the turn queue. +- `receive_turn` yields until `turn/completed` **whatever its status**: + +| codex notification | AgentEvent | +|---|---| +| `item/agentMessage/delta` | `TextDelta` | +| `item/reasoning/textDelta` / `summaryTextDelta` | `ThinkingDelta` | +| `item/started {commandExecution}` | `ToolUse(id, "Bash", {command, cwd})` | +| `item/commandExecution/outputDelta` | buffered → flushed on `item/completed` as `ToolResult(aggregatedOutput, is_error = exitCode != 0)` | +| `item/started {fileChange}` | per `changes[]` entry: best-effort pre-apply `spec.snapshot(path)` then `ToolUse(f"{itemId}:{n}", "Edit", {file_path: path})` | +| `item/fileChange/patchUpdated` / `item/completed {fileChange}` | per entry: `ToolResult(f"{itemId}:{n}", diff)` — multi-file changes produce N pairs, so the diff panel gets every file | +| `item/started {mcpToolCall}` | `ToolUse(id, "mcp____", input)` | +| `item/mcpToolCall/progress` / `item/completed {mcpToolCall}` | `ToolResult` | +| `item/started|completed {webSearch}` | `ToolUse`/`ToolResult("WebSearch")` | +| `item/plan/delta` + plan items | `SystemEvent("codex_plan", ...)` (logged v1; UI chip later) | +| `thread/tokenUsage/updated` (matching turnId) | retained: `last` breakdown + `modelContextWindow` | +| `model/rerouted` | `ModelObserved(new_model)` | +| `turn/completed` | `TurnCompleted(thread_id, model, usage=retained tokenUsage → NormalizedUsage (cachedInputTokens→cache_read_tokens), total_cost_usd=pricing.compute(model, usage) — None when the model has no table entry, duration_ms=turn.durationMs, context_window, status=turn.status, error=turn.error)` — `interrupted` terminates the iterator cleanly so /stop's graceful-wait works | +| generic `error {willRetry}` | `willRetry=true` → `SystemEvent`; else raise `CodexTurnError` into the engine's existing error path | +| unknown | debug log | + +Tool names reuse the Claude vocabulary ("Bash"/"Edit"/"WebSearch"/`mcp__*`) so the +existing UI (tool chips, `_maybe_broadcast_file_changed` diff panel keyed on +`input.file_path`, snapshot-vs-current diffing) works unchanged. Inline +`EditToolBlock` old/new rendering shows the panel path only — acceptable, documented. + +### Approvals / interactive / wakeups + +- Default config: `approval_policy: never` + full-access sandbox → no approval + requests, parity with today. Tightened configs route + `item/commandExecution/requestApproval` / `item/fileChange/requestApproval` / + `item/permissions/requestApproval` → `InteractionHub.request_approval(...)` + (auto-decline on non-interactive sources, same rule as today) → decision + `{decision: accept|decline}`. `item/tool/requestUserInput` and + `mcpServer/elicitation/request`: v1 auto-decline + log. +- `interrupt()` → `turn/interrupt {threadId, turnId}`. +- ScheduleWakeup: new nerve-registry tool `schedule_wakeup` (same clamping/semantics, + handler calls `spec.record_wakeup` → identical wakeup rows; the sweep fires the + session with its sticky backend, so no cross-backend hazard per §3). Exposure rules: + ClaudeBackend excludes it (CLI built-in + hook already cover it); the shared HTTP MCP + endpoint **rejects it for satellite (`source="external"`) sessions in the handler** + (engine-run wakeups on never-engine-run sessions make no sense); prompt tool list + (`prompts._format_tool_list`) receives the backend's exclusion set so claude prompts + don't advertise it. + +## 8. Nerve tools for codex sessions — session-bound MCP + +Reuse the production Streamable HTTP endpoint: + +- Per-thread `config.mcp_servers.nerve = {url: "http://127.0.0.1:/mcp/v1", + bearer_token_env_var: "NERVE_MCP_TOKEN", tool_timeout_sec: 3600}`. + `bearer_token_env_var` confirmed supported in 0.144.1 (the existing external-agents + writer uses `http_headers = {Authorization = "Bearer …"}` — also fine as fallback; + final choice at implementation, env-var preferred to keep the token out of any TOML). + If per-thread `config.mcp_servers` proves inert in practice (schema allows it), the + fallback is writing `~/.nerve/codex/config.toml` at spawn — same content, still + per-session because the process is per-session (verified by the fake-server test + asserting the chosen mechanism + the real smoke test). +- **External MCP servers parity:** enabled `McpServerConfig` entries are translated + into the same override dict (stdio `command/args/env`; http `url` + + `http_headers`/`bearer_token_env_var`). Untranslatable entries (claude-plugin MCPs — + those ride `options.plugins` on claude) are skipped with a warning. Claude Code + plugins are claude-only by nature; documented in §14. +- **Session binding:** mint a per-session JWT with claims `{nerve_session_id, + aud: "nerve-mcp", exp: none}` (revocation = gateway secret rotation, exactly the + trust model of the existing external tokens; no 24h expiry — a busy session never + gets swept, so a 24h token would 401 mid-conversation). Auth changes: + `gateway.auth.decode_token` gains an `audience=` passthrough and — because PyJWT + rejects any token carrying `aud` when the caller doesn't request one — + `authenticate_mcp` tries aud-less first, then `aud="nerve-mcp"`; the decoded payload + is **returned to the mount** and stashed for the request so the `ctx_resolver` can + read `nerve_session_id` (today http.py:190 discards it). Claim present → ToolContext + binds the real session id (notify/ask_user/memorize/task_* attribute correctly, the + audit writer keeps working); absent → `SatelliteSessionResolver`, unchanged. +- `ask_user(wait=true)` blocks inside the tool call → works over HTTP MCP with the 1h + `tool_timeout_sec`. +- Codex's `DynamicToolSpec`/`item/tool/call` (client-registered tools, would remove the + HTTP hop) is not referenced from v2 thread/turn params in 0.144.1 → future work. + +## 9. System prompt & prompt-cache strategy + +- Nerve's rendered system prompt → **`developerInstructions`** at thread + start/resume/fork (supported on all three — client rebuilds keep the prompt). + `baseInstructions` (codex harness behavior) untouched. +- Backend appends a `` block (not in prompts.py — it stays neutral): + nerve tools live on the `nerve` MCP server; use `schedule_wakeup` (not + ScheduleWakeup); AskUserQuestion/plan-mode don't exist — use `ask_user`. +- Workspace AGENTS.md would duplicate the identity bundle → thread config + `project_doc_max_bytes: 0`. +- Prompt caching: OpenAI caches automatically (no TTL knob) → `supports_cache_ttl=False` + skips cache_policy for codex sessions; `cachedInputTokens` maps to + `cache_read_input_tokens` in the usage contract so diagnostics stay meaningful. + +## 10. Config, model, auth, cost + +```yaml +agent: + backend: claude # claude | codex — new interactive sessions + cron_backend: null # null → backend (new cron/hook sessions only; wakeups inherit) +codex: + bin_path: codex # PATH-resolved; min version check (>= 0.144) + home_dir: ~/.nerve/codex # isolated CODEX_HOME (auth + config + sessions) + model: gpt-5.6-codex + cron_model: null # null → codex.model + auth: chatgpt # chatgpt | api_key + api_key: null # or api_key_env: OPENAI_API_KEY + sandbox: danger-full-access # read-only | workspace-write | danger-full-access + approval_policy: never # never | on-request | untrusted (v2 protocol set) + effort_map: {max: xhigh, xhigh: xhigh, high: high, medium: medium, low: low} + web_search: true + tool_timeout_sec: 3600 + turn_idle_timeout_seconds: null # null → agent.cli_idle_timeout_seconds; engine + # resolves into SessionSpec.idle_timeout + pricing: # $/1M tokens; cached input billed at cached rate + gpt-5.6-codex: {input: 5.0, cached_input: 0.5, output: 30.0} # default model MUST have an entry + gpt-5.6-sol: {input: 5.0, cached_input: 0.5, output: 30.0} + gpt-5.6-terra: {input: 2.5, cached_input: 0.25, output: 15.0} + gpt-5.6-luna: {input: 1.0, cached_input: 0.1, output: 6.0} +``` + +- **Auth:** isolated `CODEX_HOME` keeps nerve's auth/config/session state away from + Artem's `~/.codex` (which external-agents manages and points back at nerve). + `api_key` → backend runs `account/login/start {type: apiKey}` once per home when + `account/read` says logged-out. `chatgpt` → one-time manual + `CODEX_HOME=~/.nerve/codex codex login`; unauthenticated state surfaces that exact + command in the error. Note: session **title generation** stays on the Anthropic + direct client — in an (unlikely) Anthropic-credential-less deployment titles stay + placeholders; documented, out of scope. +- **DB:** migration `v038_session_backend.py` — `ALTER TABLE sessions ADD COLUMN + backend TEXT` (read default `claude`). Stamped at first client build; resume guard + per §3. +- **Cost accounting:** + - claude (cost_is_cumulative=True): unchanged — `_sdk_cumulative_cost` high-water + mark + `compute_turn_cost` diff + estimate backstop. + - codex: `TurnCompleted.total_cost_usd` is already per-turn (backend-priced; + **None when the model isn't in the pricing table — never estimated**). Engine + passes it directly to `record_turn_usage` and **skips** `compute_turn_cost`, + the `_sdk_cumulative_cost` write, and `_reset_cost_baseline` (all gated on the + capability flag). + - Usage dict: `NormalizedUsage.to_anthropic_shape()` is what lands in + `st.last_usage`, the DB usage row, and the `done` broadcast (§4) — web UI and + cache-ttl split keep working; `raw` retained inside it for diagnostics. + - Context bar: `TurnCompleted.context_window` (codex) overrides the engine's + Anthropic-hardcoded `max_context` (engine.py:2535); claude path unchanged. +- **Observability:** Langfuse instrumentation is claude-SDK-specific + (`configure_claude_agent_sdk`); codex turns produce usage rows + audit events but no + LF traces in v1 — **known gap**, listed in §14. + +## 11. Engine refactor (diff shape) + +1. `nerve/agent/backends/` per §2 (events, base, claude extraction, codex). +2. engine.py: + - `__init__`: build backend registry from config (claude always; codex when + configured); `_session_backends: dict[str, str]` runtime map (SessionManager + keeps storing the bare `AgentClient` — `stop_session`/`shutdown` call + `interrupt()`/`disconnect()` on the protocol object, no tuples). + - `_resolve_backend(session_row, source)` per §3 (sticky-first). + - `_get_or_create_client`: same orchestration (recall freeze, cache_ttl now gated, + interaction hub registration, DB stamps incl. `backend`) ending in + `backend.create_client(spec)`; resume validation via + `backend.validate_resume_target`; `resume_dropped` from codex clears the column. + - `_run_inner`: `client.start_turn(TurnInput(...))` + `async for event in + client.receive_turn()` → `_process_agent_event`; timeout wrapper unchanged + (reads `SessionSpec.idle_timeout`); cancel path persists + `client.native_session_id`. + - `_process_sdk_message` → `_process_agent_event` (same body, event-keyed; + `ModelObserved` feeds `_track_serving_model`). + - `_drain_pending_messages`/`_idle_stream_watcher`: gated on + `supports_idle_stream`; framing logic stays, consuming + `try_receive_idle_event`/`receive_idle_event`. + - Claude-specific helpers removed (moved), thin deprecation re-exports only where + tests need a transition (§12 updates them properly instead where possible). + - Cron/hook call sites stop pre-resolving models (§3). +3. `nerve/mcp_server/auth.py` + `http.py`: audience-aware decode, payload surfaced to + ctx_resolver, session-claim binding (§8). +4. `nerve/agent/tools/`: `schedule_wakeup` handler + registry entry; adapters accept an + exclusion set; HTTP endpoint rejects `schedule_wakeup` for satellite sessions; + `prompts._format_tool_list` takes exclusions. +5. config.py: `AgentConfig.backend/cron_backend` + `CodexConfig` dataclass + + `NerveConfig.codex` + validation (unknown backend → hard error; codex+ollama-model + → error; approval_policy restricted to the v2 set; pricing table must cover + `codex.model`). +6. DB migration `v038_session_backend.py`. +7. Frontend: interaction-type union + `ApprovalBlock` (command/file variants) + + answer routing; `npm run build`. +8. docs: this plan; `docs/architecture.md` engine section; `docs/config.md` codex + section; `docs/sdk-sessions.md` (backend column, sticky resolution, cross-backend + guard). + +## 12. Testing + +New (offline; no real codex binary in CI): + +- `tests/test_backend_events.py` — claude translate_message parity: text/thinking/ + tool-use/tool-result blocks, parent_tool_use_id, ModelObserved gating on sub-agent + messages, ResultMessage → TurnCompleted (usage passthrough, cumulative cost), + ordered_blocks/broadcast outcomes equal to the pre-refactor behavior (reusing + existing engine-test fixtures). +- `tests/test_codex_protocol.py` — notification→event mapping per §7's table (fixtures + shaped from the exported schema): multi-file fileChange fan-out, outputDelta + buffering + exitCode→is_error, tokenUsage retention → NormalizedUsage mapping + (cachedInputTokens→cache_read), turn status completed/interrupted/failed, generic + error willRetry split, unknown-notification tolerance; pricing math incl. cached + tokens and the unknown-model→None rule; usage `to_anthropic_shape` contract. +- `tests/test_codex_appserver.py` — **fake app-server** subprocess + (`tests/fixtures/fake_codex_appserver.py`, scripted JSONL JSON-RPC): handshake, + turn streaming, **async approval round-trip with interleaved deltas** (proves the + reader never blocks — the exact deadlock the official SDK has), interrupt (incl. + response arriving while an approval is pending), `interrupted` turn terminating + receive_turn, resume + fork param shapes, **resume-miss → fresh-start fallback + + resume_dropped signal**, process death mid-turn → transport error → engine retry + path, unknown server-request safe default, mcp_servers/config-override payload + assertion. +- `tests/test_engine_backend_selection.py` — sticky resolution (stored backend beats + config; wakeup on claude session under `cron_backend: codex` stays claude), + new-session config routing + `backend_override`, cross-backend guard clears native + id, `sessions.backend` stamping, cron call sites passing model=None through, + ollama+codex validation error, excluded-tools filtering (claude excludes + schedule_wakeup; prompt tool list respects it). +- `tests/test_mcp_session_binding.py` — aud-less token → satellite path unchanged; + `aud="nerve-mcp"` + session claim → real-session ToolContext; wrong aud → 401; + schedule_wakeup rejected for satellites; audit rows carry the real session id. +- Migration test upsert into the existing migration-suite pattern (v038). +- **Updated (enumerated — mechanical, assertions preserved):** `tests/test_engine.py` + (`_effective_effort`/`_process_sdk_message`/`_track_serving_model`/ + `_sdk_resume_file_exists` move → import from backends.claude / re-keyed on events), + `tests/test_autonomous_turns.py` (drain fixtures move to event-shaped fakes), + `tests/test_cache_policy.py` (`_build_env` import), `tests/test_interactive.py` + (hub split; PermissionResult assertions move to the claude-adapter test). +- Everything else must pass untouched. Full suite green before review. + +Manual/integration (not CI): `scripts/codex_smoke.py` — real app-server: auth check, +one thread, one trivial turn, assert text + usage + thread id + **RSS of the +app-server process** (recorded in this doc §17 before any wide flip), fileChange +`item/started` pre-apply ordering probe (validates the snapshot assumption; if it +fires post-apply, switch snapshots to reverse-applying the received diff — noted in +code where the fallback goes). + +## 13. Risks & mitigations + +| Risk | Mitigation | +|---|---| +| App-server API drift (experimental surface) | min-version check; defensive parsing; schema snapshot marker; smoke script | +| fileChange `item/started` fires post-apply → snapshot captures new content | smoke probe; fallback: reconstruct pre-image by reverse-applying the received unified diff | +| Per-thread `config.mcp_servers` override ignored | fake-server asserts what we send; smoke verifies effect; fallback: config.toml in isolated CODEX_HOME (still per-session) | +| Beta Python SDK temptation | rejected with verified reason (reader-thread approval dispatch); documented | +| Cross-backend resume corruption | sticky backend column + guard; wakeups inherit | +| Per-session JWT | aud-scoped, session-claimed, env-only, no exp (revocation = secret rotation — same trust model as existing external tokens) | +| Cost table drift | config-driven; unknown model → cost None, never estimated (codex path bypasses the estimate backstop by design) | +| App-server RSS per session | measured in smoke before flip; idle sweep already bounds live client count | +| `turn/steer` unused → messages queue during turns | same as today (per-session serialization); steering out of scope v1 | +| Plan mode / AskUserQuestion absent on codex | ask_user covers; approvals UI ships for tightened sandboxes | +| Live instance safety | worktree only; default config unchanged; no restart without Artem | + +## 14. Out of scope (v1) — explicit non-parity list + +- Langfuse tracing for codex turns (usage rows + audit only). +- Claude Code plugin MCPs on codex (plugin lifecycle is claude-CLI-owned). +- PDF/document inputs on codex (inline note to the model instead of silent drop). +- Dynamic client-registered tools over app-server; `turn/steer`; `thread/rollback`; + codex hooks system; realtime/voice. +- Mixed-backend forking (guard refuses). +- Codex-side thread naming/archive sync. +- `model_provider` overrides (incl. Ollama-through-codex — validation error). + +## 15. Rollout + +1. Merge with `backend: claude` default → zero behavior change; suite green. +2. Artem: `CODEX_HOME=~/.nerve/codex codex login` (or api_key config); run + `scripts/codex_smoke.py`; record RSS + snapshot-ordering results in §17. +3. A/B: `backend_override` on a fresh session, or `agent.cron_backend: codex` (safe + now — sticky resolution keeps existing sessions and their wakeups on claude). +4. Watch: usage rows (`raw`), cost attribution, audit trail, approval UX. +5. Full flip (`agent.backend: codex`) = config edit + `nerve restart` — Artem's call. + +## 16. Review log + +- v1 → v2 (2026-07-10): adversarial subagent review (agent a4093b396028e149b) found 4 + blockers / 12 majors / 14 minors — all incorporated: JWT audience handling (§8), + `_sdk_resume_file_exists`/`_safe_disconnect` into the seam (§5), sticky backend + resolution (§3), turn-completion contract rewritten around + `thread/tokenUsage/updated` + `turn.status` (§0/§7), ModelObserved event (§4), + usage-shape contract (§4/§10), capability-gated cost bookkeeping + pricing entry for + the default model (§10), SessionManager stores protocol clients (§11), external MCP + translation (§8), cron model call sites (§3), approval frontend in scope (§7), + InteractionHub split (§7), idle-drain contract (§3/§5), no-exp session JWT (§8), + multi-file fileChange fan-out (§7), codex resume-miss recovery (§7), plus all minors + (image conversion, `on-failure` removed, effort-as-string, schedule_wakeup scoping, + timeout plumbing, langfuse/title-gen/PDF notes, context bar, `interrupted` + termination, migration v038, test-file enumeration). + +## 17. Post-implementation verification notes + +**Implemented 2026-07-10** (branch `pufit/codex-backend`). Deviations and +findings vs the plan: + +- **MCP config mechanism:** spawn-level `-c key=value` overrides only (the + per-thread `config` dict path was dropped — process==session makes spawn + scope per-session anyway, and `-c` is the mechanism the official SDKs use). + Asserted by `test_config_overrides_carry_mcp_bridge` against the fake + app-server's argv mirror. +- **Resume-miss recovery** implemented as a `client.resume_dropped` flag + (not the `ResumeDroppedError` carrier exception) — the backend recovers + internally and the engine clears the DB column on the flag. +- **`build_backends` constructs BOTH backends always** (construction is + side-effect-free except the codex-home mkdir): a stored `backend=codex` + session must stay resumable after config flips back to claude. +- **got_content semantics** (drain + retry gating) now key on *content + events* rather than mere AssistantMessage arrival — an empty assistant + message no longer opens/persists an empty autonomous turn (behavior + delta, deliberate; documented in test updates). +- **Failed codex turns** complete the turn with an inline + `⚠️ Turn failed: …` note + `status=failed` in result meta (engine's + transport-death path stays reserved for actual runtime death). +- Offline verification: fake app-server suite (11 tests) proves the + approval round-trip streams deltas while pending (the beta-SDK deadlock + case), interrupt→`turn/completed(interrupted)` terminates `receive_turn`, + transport death raises into the engine retry path, resume-miss falls back + fresh, multi-file fileChange fan-out + pre-apply snapshots, usage + normalization (cached ⊆ input split) and pricing math. +- Full pytest suite green (see PR); frontend `tsc --noEmit` + `npm run + build` clean with the new ApprovalCard. + +**Pending live verification (needs Artem: `CODEX_HOME=~/.nerve/codex codex +login`, then `scripts/codex_smoke.py`):** real-handshake auth, app-server +RSS per session, fileChange `item/started` pre/post-apply ordering probe +(smoke prints the verdict + the reverse-diff fallback instruction), live +per-turn usage/cost sanity, `mcp_servers.nerve` override effectiveness +against the real binary. diff --git a/docs/sdk-sessions.md b/docs/sdk-sessions.md index 087193a..4fd603a 100644 --- a/docs/sdk-sessions.md +++ b/docs/sdk-sessions.md @@ -1,5 +1,15 @@ # SDK Session Management +> **Multi-backend note (2026-07):** everything below applies to the Claude +> backend specifically; the same *concepts* (native session id in +> `sessions.sdk_session_id`, resume, fork) are implemented per backend +> behind `nerve/agent/backends/`. The `sessions.backend` column records +> which runtime owns a session — resolution is sticky (stored backend +> beats config) and cross-backend resume is refused (the native id is +> cleared and the conversation restarts). Codex threads resume via +> `thread/resume` and fork via `thread/fork`; a stale codex thread id is +> recovered by falling back to a fresh thread (`resume_dropped`). + ## Core Principle The Claude Agent SDK manages conversation context internally. **Never load messages into prompts manually.** Use `resume` and `fork_session` to give the agent context. diff --git a/nerve/agent/backends/__init__.py b/nerve/agent/backends/__init__.py new file mode 100644 index 0000000..79977ac --- /dev/null +++ b/nerve/agent/backends/__init__.py @@ -0,0 +1,100 @@ +"""Agent backends — pluggable runtimes behind one engine seam. + +The engine constructs every configured backend once at init via +:func:`build_backends` and resolves one per session (sticky — see +docs/plans/codex-backend.md §3). Backends receive their collaborators +through :class:`BackendDeps` to avoid engine↔backend import cycles. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any, Callable + +from nerve.agent.backends.base import ( + AgentBackend, + AgentClient, + BackendCapabilities, + BackendError, + ResumeDroppedError, + SessionSpec, + TransportDiedError, + TurnInput, +) +from nerve.agent.backends.events import ( + AgentEvent, + ModelObserved, + NormalizedUsage, + SubagentStarted, + SystemEvent, + TextDelta, + ThinkingDelta, + ToolResult, + ToolUse, + TurnCompleted, +) + +if TYPE_CHECKING: + from nerve.config import NerveConfig + from nerve.db import Database + + +@dataclass +class BackendDeps: + """Collaborators a backend may need, provided by the engine. + + Callables (rather than direct references) where the underlying value + is hot-reloadable or wired up after engine construction. + """ + + config: "NerveConfig" + db: "Database" + registry: Any # ToolRegistry + tool_ctx_factory: Callable[[str], Any] # session_id -> ToolContext + external_mcp_servers: Callable[[], list] # live McpServerConfig cache + claude_plugins: Callable[[], list] = field(default=lambda: []) + # Codex tool-bridge collaborators (None until the gateway wires them): + gateway_port: Callable[[], int | None] = field(default=lambda: None) + mint_session_token: Callable[[str], str] | None = None + + +def build_backends(deps: BackendDeps) -> dict[str, AgentBackend]: + """Construct every known backend. + + Both backends are always constructed (construction is cheap and has + no side effects beyond mkdir of the codex home): a session created on + codex must stay resumable even after the operator flips the config + default back to claude — the sticky ``sessions.backend`` column + routes to the stored backend regardless of current config. + """ + from nerve.agent.backends.claude import ClaudeBackend + from nerve.agent.backends.codex import CodexBackend + + return { + "claude": ClaudeBackend(deps), + "codex": CodexBackend(deps), + } + + +__all__ = [ + "AgentBackend", + "AgentClient", + "AgentEvent", + "BackendCapabilities", + "BackendDeps", + "BackendError", + "ModelObserved", + "NormalizedUsage", + "ResumeDroppedError", + "SessionSpec", + "SubagentStarted", + "SystemEvent", + "TextDelta", + "ThinkingDelta", + "ToolResult", + "ToolUse", + "TransportDiedError", + "TurnCompleted", + "TurnInput", + "build_backends", +] diff --git a/nerve/agent/backends/base.py b/nerve/agent/backends/base.py new file mode 100644 index 0000000..8798fbe --- /dev/null +++ b/nerve/agent/backends/base.py @@ -0,0 +1,175 @@ +"""Agent backend seam — protocols and shared datatypes. + +The engine talks to agent runtimes exclusively through +:class:`AgentBackend` / :class:`AgentClient`. Backend-specific behavior +the engine must act on is declared in :class:`BackendCapabilities` — +never discovered via ``isinstance``. + +See docs/plans/codex-backend.md §3 for the full design rationale. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import ( + Any, + AsyncIterator, + Awaitable, + Callable, + Protocol, + runtime_checkable, +) + +from nerve.agent.backends.events import AgentEvent + +# async (session_id, file_path, content_or_none) -> None +SnapshotFn = Callable[[str, str, "str | None"], Awaitable[None]] +# async (session_id, tool_input) -> None +WakeupFn = Callable[[str, dict], Awaitable[Any]] + + +class BackendError(Exception): + """Base class for backend failures the engine's retry path handles.""" + + +class TransportDiedError(BackendError): + """The backend subprocess/stream died mid-operation.""" + + +class ResumeDroppedError(BackendError): + """The stored native session id could not be resumed. + + Raised (or reported) by ``create_client`` after it has already + recovered by starting a fresh native session — the engine must clear + the persisted ``sdk_session_id``. Carries the live client so no work + is lost. + """ + + def __init__(self, message: str, client: "AgentClient | None" = None): + super().__init__(message) + self.client = client + + +@dataclass +class TurnInput: + """One user turn, engine-normalized. + + ``images``: list of dicts in one of two shapes — + ``{"media_type": str, "data": }`` or ``{"path": str}``. + ``documents``: PDF/document blocks (Claude-native; other backends + surface an inline unsupported-input note instead of dropping them). + """ + + text: str + images: list[dict[str, Any]] | None = None + documents: list[dict[str, Any]] | None = None + + +@dataclass +class SessionSpec: + """Everything a backend needs to build a client for one session.""" + + session_id: str + source: str + model: str | None + effort: str + system_prompt: str + cwd: str + resume_native_id: str | None = None + fork: bool = False + interactive: Any = None # InteractionHub (typed Any to avoid cycles) + snapshot: SnapshotFn | None = None + record_wakeup: WakeupFn | None = None + cache_ttl: str = "5m" + max_turns: int = 100 + idle_timeout: float = 900.0 + extra: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class BackendCapabilities: + """Engine-visible behavioral differences between backends.""" + + cost_is_cumulative: bool = True + supports_idle_stream: bool = True + supports_cache_ttl: bool = True + interactive_builtins: bool = True + reports_context_window: bool = False + + +@runtime_checkable +class AgentClient(Protocol): + """A live, per-session agent runtime (one subprocess under the hood).""" + + @property + def native_session_id(self) -> str | None: + """Backend-native session/thread id, once known. + + Codex knows it at ``thread/start``; Claude learns it from the + first stream message. The engine's cancel-persistence path reads + this property (never raw stream messages). + """ + ... + + async def connect(self) -> None: ... + + async def start_turn(self, turn: TurnInput) -> None: ... + + def receive_turn(self) -> AsyncIterator[AgentEvent]: + """Yield events until (and including) ``TurnCompleted``. + + Must terminate for interrupted and failed turns too — the /stop + flow's graceful wait depends on it. + """ + ... + + async def interrupt(self) -> None: ... + + async def disconnect(self) -> None: + """Full teardown, owning every process/transport internal.""" + ... + + def is_alive(self) -> bool: ... + + # -- autonomous/idle stream (only when supports_idle_stream) ------- # + + def try_receive_idle_event(self) -> AgentEvent | None: + """Non-parking probe of the between-turns stream (None = empty).""" + ... + + async def receive_idle_event(self, timeout: float) -> AgentEvent | None: + """Park up to ``timeout`` seconds for a between-turns event.""" + ... + + def buffer_used(self) -> int: + """Bytes buffered in the native stream (0 when N/A).""" + ... + + +class AgentBackend(Protocol): + """Factory + policy for one agent runtime family.""" + + name: str + capabilities: BackendCapabilities + + def default_model(self, source: str) -> str: + """Default model for a session source when no explicit override.""" + ... + + async def create_client(self, spec: SessionSpec) -> AgentClient: + """Build and connect a client. May raise :class:`ResumeDroppedError` + (carrying the recovered client) when a stale resume target had to + be discarded.""" + ... + + def validate_resume_target(self, native_id: str, cwd: str) -> bool: + """Cheap pre-check that a stored native id is still resumable. + + Backends without a cheap check return True and rely on + ``create_client``'s resume-miss recovery instead. + """ + ... + + def excluded_tools(self) -> set[str]: + """Nerve-registry tool names NOT to expose for this backend.""" + ... diff --git a/nerve/agent/backends/claude.py b/nerve/agent/backends/claude.py new file mode 100644 index 0000000..5223e53 --- /dev/null +++ b/nerve/agent/backends/claude.py @@ -0,0 +1,1085 @@ +"""Claude backend — the Claude Agent SDK behind the backend seam. + +Everything Claude-specific that used to live inline in +``nerve/agent/engine.py`` moved here unchanged in behavior: + +* ``ClaudeAgentOptions`` assembly (system-prompt file spill, thinking / + effort / betas / extra_args / env / plugins / per-session MCP servers) +* PreToolUse / PostToolUse hooks (file snapshots, image validation, + ScheduleWakeup capture, background-agent permission parity) +* the ``can_use_tool`` permission adapter (interactive built-ins → + :class:`~nerve.agent.interactive.InteractiveToolHandler`) +* resume-target validation (the ``~/.claude/projects`` .jsonl check) +* the hardened disconnect path (subprocess kill + anyio task-group disarm) +* SDK message → normalized :mod:`nerve.agent.backends.events` translation +* the between-turns idle stream (autonomous CLI turns) + +The engine never imports ``claude_agent_sdk`` — this module is the only +place those types exist on the agent path. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import logging +import os +import re +from typing import Any, AsyncIterator + +from claude_agent_sdk import ( + AssistantMessage, + ClaudeAgentOptions, + ClaudeSDKClient, + ResultMessage, + SystemMessage, + TextBlock, + ToolResultBlock, + ToolUseBlock, + UserMessage, +) +from claude_agent_sdk._errors import CLIConnectionError +from claude_agent_sdk.types import ( + HookMatcher, + PermissionResult, + PermissionResultAllow, + PermissionResultDeny, + ToolPermissionContext, +) + +from nerve.agent.backends import events as ev +from nerve.agent.backends.base import ( + AgentClient, + BackendCapabilities, + SessionSpec, + TransportDiedError, + TurnInput, +) +from nerve.agent.backends.images import validate_image_data, validate_image_file +from nerve.agent.cache_policy import cache_ttl_env +from nerve.agent.interactive import ( + FILE_MODIFY_TOOLS, + INTERACTIVE_TOOLS, + InteractiveToolHandler, + _read_file_safe, +) + +logger = logging.getLogger(__name__) + +try: + from claude_agent_sdk import ThinkingBlock +except ImportError: # pragma: no cover - depends on SDK version + ThinkingBlock = None + +# Linux execve() limits a single argv element to MAX_ARG_STRLEN = PAGE_SIZE * 32 +# = 131,072 bytes on common configurations. The Claude Agent SDK passes the +# system prompt inline as `--system-prompt `, which makes the string a +# single argv element. When SOUL.md + TASK.md + AGENTS.md + TOOLS.md + +# MEMORY.md + recalled memU summaries cross that boundary, execve() returns +# E2BIG ("Argument list too long") and Claude Code fails to start. +# +# We sidestep the limit by writing the prompt to a file and passing +# `SystemPromptFile = {"type": "file", "path": ...}` (which the SDK converts +# to `--system-prompt-file ` — the path string is short). +# +# Threshold below which we keep passing inline (preserves prompt-cache hit +# behavior for small, stable prompts). Set conservatively well under the +# kernel limit to leave room for env/argv overhead. +SYSTEM_PROMPT_INLINE_MAX = 100_000 # bytes + + +# ------------------------------------------------------------------ # +# SDK message → normalized events # +# ------------------------------------------------------------------ # + +def translate_message(message: Any) -> list[ev.AgentEvent]: + """Translate one SDK stream message into normalized agent events. + + One message may yield several events (a multi-block + ``AssistantMessage``). Unknown message types yield nothing. + """ + out: list[ev.AgentEvent] = [] + + if isinstance(message, AssistantMessage): + parent_id = getattr(message, "parent_tool_use_id", None) + # Serving-model observation — main-agent messages only: sub-agents + # legitimately run different models (Agent tool `model` opt, + # built-in agent defaults), which must not pollute turn cost + # attribution or fire serving-model change events. + msg_model = getattr(message, "model", None) + if msg_model and parent_id is None: + out.append(ev.ModelObserved(model=msg_model)) + + for block in message.content: + if isinstance(block, TextBlock): + out.append(ev.TextDelta( + text=block.text, parent_tool_use_id=parent_id, + )) + elif ThinkingBlock is not None and isinstance(block, ThinkingBlock): + thinking = getattr(block, "thinking", "") or "" + if not thinking: + # Empty thinking block (e.g. display="omitted", or + # simple queries on low effort). Nothing visible to + # render — never fall back to str(block) as that + # leaks the ThinkingBlock(...) repr into the UI. + continue + out.append(ev.ThinkingDelta( + text=thinking, parent_tool_use_id=parent_id, + )) + elif isinstance(block, ToolUseBlock): + tool_input = getattr(block, "input", {}) + tool_name = getattr(block, "name", None) or str(block) + tool_use_id = getattr(block, "id", None) + out.append(ev.ToolUse( + tool_use_id=tool_use_id, + name=tool_name, + input=tool_input, + parent_tool_use_id=parent_id, + )) + # Sub-agent lifecycle. Claude Code 2.1.x renamed the + # subagent-spawning tool from ``Task`` → ``Agent``; match + # both so old session history still opens panels on replay. + if tool_name in ("Task", "Agent") and tool_use_id: + out.append(ev.SubagentStarted( + tool_use_id=tool_use_id, + subagent_type=str( + tool_input.get( + "subagent_type", tool_input.get("model", "agent"), + ) + ), + description=str(tool_input.get("description", "")), + model=str(tool_input.get("model", "")) or None, + )) + elif isinstance(block, ToolResultBlock): + out.append(_translate_tool_result(block, parent_id)) + + elif isinstance(message, UserMessage): + parent_id = getattr(message, "parent_tool_use_id", None) + content = getattr(message, "content", []) + if isinstance(content, list): + for block in content: + if isinstance(block, ToolResultBlock): + out.append(_translate_tool_result(block, parent_id)) + + elif isinstance(message, SystemMessage): + subtype = getattr(message, "subtype", "") or "" + data = dict(getattr(message, "data", None) or {}) + # Older SDK shapes put some fields at the message top level — + # merge them so the engine reads one dict. + for key in ("task_id", "description", "status", "tool_use_id", "summary"): + if key not in data: + value = getattr(message, key, None) + if value is not None: + data[key] = value + out.append(ev.SystemEvent(subtype=subtype, data=data)) + + elif isinstance(message, ResultMessage): + usage = ( + ev.NormalizedUsage.from_anthropic(message.usage) + if message.usage else None + ) + out.append(ev.TurnCompleted( + native_session_id=message.session_id, + model=None, # claude reports the model per AssistantMessage + usage=usage, + # Cumulative per CLI process — the engine diffs it + # (cost_is_cumulative capability). + total_cost_usd=getattr(message, "total_cost_usd", None), + duration_ms=getattr(message, "duration_ms", None), + duration_api_ms=getattr(message, "duration_api_ms", None), + num_turns=getattr(message, "num_turns", None), + status="completed", + )) + + return out + + +def _translate_tool_result( + block: ToolResultBlock, parent_id: str | None, +) -> ev.ToolResult: + return ev.ToolResult( + tool_use_id=getattr(block, "tool_use_id", None), + content=block.content, + is_error=bool(getattr(block, "is_error", False) or False), + parent_tool_use_id=parent_id, + ) + + +# ------------------------------------------------------------------ # +# can_use_tool adapter # +# ------------------------------------------------------------------ # + +class ClaudeToolPermissions: + """Adapts the backend-neutral :class:`InteractiveToolHandler` into the + SDK's ``can_use_tool`` callback. + + * File-modifying tools trigger a pre-execution snapshot (redundant + with the PreToolUse hook, kept as belt-and-suspenders). + * Interactive built-ins pause via the hub and translate its outcome + into ``PermissionResultAllow/Deny``. + * Everything else auto-approves with zero overhead. + """ + + def __init__(self, hub: InteractiveToolHandler): + self._hub = hub + + async def can_use_tool( + self, + tool_name: str, + tool_input: dict[str, Any], + context: ToolPermissionContext, + ) -> PermissionResult: + hub = self._hub + # Capture pre-execution file snapshot for diff tracking + # (also done via PreToolUse hook in the backend as primary path). + if hub.snapshot_fn and tool_name in FILE_MODIFY_TOOLS: + file_path = tool_input.get("file_path") or tool_input.get("notebook_path") + if file_path and hub.mark_snapshotted(file_path): + content = _read_file_safe(file_path) + try: + await hub.snapshot_fn(hub.session_id, file_path, content) + except Exception as e: + logger.warning( + "Failed to save file snapshot for %s: %s", file_path, e, + ) + + if tool_name not in INTERACTIVE_TOOLS: + return PermissionResultAllow() + + # Non-interactive channels: deny immediately to prevent deadlocks + if not hub.interactive_capable: + deny_messages = { + "AskUserQuestion": ( + "AskUserQuestion is not available in this channel. " + "Use the Nerve `ask_user` tool to ask the user questions asynchronously." + ), + "EnterPlanMode": ( + "Plan mode is not available in non-web sessions. " + "Proceed with implementation directly." + ), + "ExitPlanMode": ( + "Plan mode is not available in non-web sessions." + ), + } + logger.info( + "Session %s: auto-denying %s (non-interactive channel)", + hub.session_id, tool_name, + ) + return PermissionResultDeny( + message=deny_messages.get( + tool_name, + f"{tool_name} is not available in this channel.", + ) + ) + + outcome = await hub.request_interaction(tool_name, tool_input) + if outcome.cancelled: + return PermissionResultDeny( + message=outcome.message or "Session stopped by user.", + interrupt=True, + ) + if outcome.denied: + return PermissionResultDeny( + message=outcome.message or "Declined by user.", + ) + # For AskUserQuestion: inject answers into the tool input + if tool_name == "AskUserQuestion" and outcome.result: + updated = {**tool_input, "answers": outcome.result} + return PermissionResultAllow(updated_input=updated) + # For ExitPlanMode/EnterPlanMode: just allow + return PermissionResultAllow() + + +# ------------------------------------------------------------------ # +# Backend # +# ------------------------------------------------------------------ # + +class ClaudeBackend: + """Claude Agent SDK backend.""" + + name = "claude" + capabilities = BackendCapabilities( + cost_is_cumulative=True, + supports_idle_stream=True, + supports_cache_ttl=True, + interactive_builtins=True, + reports_context_window=False, + ) + + def __init__(self, deps: Any): + self._deps = deps + self.config = deps.config + + # -- policy -------------------------------------------------------- # + + def default_model(self, source: str) -> str: + if source in ("cron", "hook"): + return self.config.agent.cron_model + return self.config.agent.model + + def excluded_tools(self) -> set[str]: + # ScheduleWakeup is a Claude CLI built-in (captured via the + # PostToolUse hook) — the registry equivalent exists for backends + # without built-ins and would be a confusing duplicate here. + return {"schedule_wakeup"} + + def validate_resume_target(self, native_id: str, cwd: str) -> bool: + """Check whether Claude Code still has the conversation .jsonl + for the given SDK session ID on this filesystem. + + The CLI stores history at:: + + ~/.claude/projects//.jsonl + + where is the absolute cwd path with every '/' + replaced by '-'. The CLI resolves the cwd symlink before + encoding, so when the workspace is itself a symlink the history + lives under the *realpath*-encoded directory. Check the realpath + first and fall back to the unresolved path. + + Best-effort: any unexpected error returns True so we still + attempt the resume and let the CLI surface the real error. + """ + try: + projects = os.path.expanduser("~/.claude/projects") + bases = [os.path.realpath(cwd)] + if cwd not in bases: + bases.append(cwd) + for base in bases: + encoded = base.replace("/", "-") + jsonl = projects + "/" + encoded + "/" + native_id + ".jsonl" + if os.path.isfile(jsonl): + return True + return False + except Exception as e: + logger.debug( + "Could not stat resume jsonl for %s: %s, assuming present", + native_id[:12], e, + ) + return True + + # -- client construction ------------------------------------------- # + + async def create_client(self, spec: SessionSpec) -> "ClaudeClient": + options = self._build_options(spec) + client = ClaudeClient(spec, options) + await client.connect() + return client + + def _build_options(self, spec: SessionSpec) -> ClaudeAgentOptions: + """Build SDK client options for a session (moved from engine).""" + config = self.config + session_id = spec.session_id + + # Pass the system prompt as a file when it's large enough to risk + # hitting Linux's MAX_ARG_STRLEN argv-element limit (see the + # SYSTEM_PROMPT_INLINE_MAX comment above). + system_prompt: str | dict[str, Any] + if len(spec.system_prompt) > SYSTEM_PROMPT_INLINE_MAX: + sp_path = self._write_system_prompt_file(session_id, spec.system_prompt) + system_prompt = {"type": "file", "path": sp_path} + logger.info( + "Session %s: system prompt %d bytes (> %d), passing via file %s", + session_id[:8], len(spec.system_prompt), + SYSTEM_PROMPT_INLINE_MAX, sp_path, + ) + else: + system_prompt = spec.system_prompt + + # Local Ollama models are reached through the proxy and speak the + # OpenAI-translated API — Anthropic-only knobs (extended thinking, + # effort, the context-1m beta) don't apply and may break + # translation, so suppress them for non-Claude models. + selected_model = spec.model or config.agent.model + is_ollama_model = ( + config.ollama.enabled and "claude" not in selected_model.lower() + ) + + thinking_config = ( + None if is_ollama_model + else self._parse_thinking_config(config.agent.thinking, selected_model) + ) + effort = ( + None if is_ollama_model + else self._effective_effort(spec.effort, selected_model) + ) + # Some subscriptions reject the context-1m beta for specific models + # (e.g. claude-sonnet-4-6) — skip the beta header for those. + betas = ( + ["context-1m-2025-08-07"] + if not is_ollama_model + and config.agent.context_1m_enabled_for(spec.model) + else [] + ) + + hooks = self._build_hooks(spec) + + def _cli_stderr(line: str) -> None: + stripped = line.rstrip() + if not stripped: + return + # Filter debug-to-stderr output by severity + if "[ERROR]" in stripped or "[FATAL]" in stripped: + logger.error("CLI stderr [%s]: %s", session_id[:8], stripped) + elif "[WARN]" in stripped: + logger.warning("CLI stderr [%s]: %s", session_id[:8], stripped) + elif "[DEBUG]" in stripped or "[INFO]" in stripped: + logger.debug("CLI stderr [%s]: %s", session_id[:8], stripped) + else: + # Non-debug lines (e.g. raw warnings from the CLI) + logger.warning("CLI stderr [%s]: %s", session_id[:8], stripped) + + extra_args: dict[str, str | None] = {"debug-to-stderr": None} + # Opus 4.7 defaults thinking.display to "omitted", returning empty + # thinking blocks with only a signature (for multi-turn continuity). + # Force "summarized" so the UI actually has thinking text to render. + # The CLI ignores this flag when thinking is disabled. + # NOTE: --thinking-display hangs on Bedrock (multi-turn after + # ToolSearch never returns). Disabled for Bedrock until the + # provider bug is fixed. + if ( + thinking_config + and thinking_config.get("type") != "disabled" + and not config.provider.is_bedrock + ): + extra_args["thinking-display"] = "summarized" + + can_use_tool = None + if spec.interactive is not None: + can_use_tool = ClaudeToolPermissions(spec.interactive).can_use_tool + + return ClaudeAgentOptions( + model=selected_model, + system_prompt=system_prompt, + max_turns=spec.max_turns, + # No permission_mode — can_use_tool callback handles all + # permissions. Interactive tools pause for user input; + # everything else auto-approves. + can_use_tool=can_use_tool, + thinking=thinking_config, + effort=effort, + betas=betas, + resume=spec.resume_native_id, + fork_session=spec.fork, + hooks=hooks, + stderr=_cli_stderr, + extra_args=extra_args, + # No allowed_tools — can_use_tool handles permissions. + # External MCP server tools are discovered at connection time, + # so we can't enumerate them upfront. + # + # Remove the CLI's cron tools — Nerve has its own cron system. + # ``ScheduleWakeup`` stays available and is handled by Nerve's + # wakeup harness (capture hook + cron-service sweep); the + # CLI's own autonomous firing is suppressed via the + # CLAUDE_CODE_DISABLE_CRON env var set in ``_build_env``. + disallowed_tools=["CronCreate", "CronList", "CronDelete"], + env=self._build_env(cache_ttl=spec.cache_ttl), + cwd=spec.cwd, + mcp_servers=self._build_mcp_servers(spec.session_id), + # Claude Code plugins — loaded via --plugin-dir so the CLI + # handles OAuth, credentials, and plugin lifecycle natively. + plugins=self._deps.claude_plugins(), + ) + + def _system_prompt_dir(self) -> "os.PathLike[str]": + """Directory where oversized system prompts are spilled to disk.""" + from pathlib import Path + d = Path(self.config.workspace) / ".nerve" / "cache" / "system_prompts" + d.mkdir(parents=True, exist_ok=True) + return d + + def _write_system_prompt_file(self, session_id: str, content: str) -> str: + """Write the system prompt to disk and return its absolute path. + + Deterministic filename so a session that reconnects (resume) gets + the same prompt without re-writing. Lazy GC of stale files (>7d). + """ + import time + from pathlib import Path + + dir_path = Path(self._system_prompt_dir()) + + cutoff = time.time() - 7 * 24 * 3600 + try: + for old in dir_path.iterdir(): + try: + if old.is_file() and old.stat().st_mtime < cutoff: + old.unlink() + except OSError: + pass + except OSError: + pass + + safe_id = re.sub(r"[^A-Za-z0-9_.-]", "_", session_id)[:120] + path = dir_path / f"{safe_id}.md" + path.write_text(content, encoding="utf-8") + return str(path) + + def _build_env(self, cache_ttl: str = "5m") -> dict[str, str]: + """Build environment variables for the SDK subprocess.""" + config = self.config + env: dict[str, str] = {} + # Prompt-cache TTL: the CLI natively supports the 1-hour TTL via + # this env var. Resolved per client build by + # nerve.agent.cache_policy — see that module for the policy. + env.update(cache_ttl_env(cache_ttl, config.provider.is_bedrock)) + # Disable the CLI's built-in cron/wakeup scheduler — Nerve owns + # wakeup timing (PostToolUse capture + cron-service sweep). The + # tool itself stays available (this flag only gates the firing). + env["CLAUDE_CODE_DISABLE_CRON"] = "1" + if config.provider.is_bedrock: + env["CLAUDE_CODE_USE_BEDROCK"] = "1" + if config.provider.aws_region: + env["AWS_REGION"] = config.provider.aws_region + if config.provider.aws_profile: + env["AWS_PROFILE"] = config.provider.aws_profile + if config.provider.aws_access_key_id: + env["AWS_ACCESS_KEY_ID"] = config.provider.aws_access_key_id + env["AWS_SECRET_ACCESS_KEY"] = config.provider.aws_secret_access_key + else: + api_key = config.effective_api_key + if api_key: + env["ANTHROPIC_API_KEY"] = api_key + if config.proxy.enabled: + env["ANTHROPIC_BASE_URL"] = ( + f"http://{config.proxy.host}:{config.proxy.port}" + ) + return env + + def _build_mcp_servers(self, session_id: str) -> dict[str, Any]: + """Build the mcp_servers dict: in-process nerve + external servers. + + Claude Code plugin MCPs are handled separately via the SDK + ``plugins`` field which lets the CLI manage OAuth and plugin + lifecycle natively. + """ + from nerve.agent.tools import build_session_mcp_server + + tool_ctx = self._deps.tool_ctx_factory(session_id) + include_hoa = bool(self.config.houseofagents.enabled) + servers: dict[str, Any] = { + "nerve": build_session_mcp_server( + self._deps.registry, tool_ctx, + include_hoa=include_hoa, + exclude=self.excluded_tools(), + ), + } + for srv in self._deps.external_mcp_servers(): + if srv.enabled and srv.name != "nerve": + try: + servers[srv.name] = srv.to_sdk_config() + except ValueError as e: + logger.warning("Skipping MCP server %r: %s", srv.name, e) + if len(servers) > 1: + logger.debug( + "Session %s: %d MCP servers (%s)", + session_id[:8], len(servers), ", ".join(servers.keys()), + ) + return servers + + def _build_hooks(self, spec: SessionSpec) -> dict: + """Build SDK hooks for this session. + + PreToolUse: file snapshots (Edit/Write/NotebookEdit) and image + validation (Read). PostToolUse: ScheduleWakeup capture, recorded + via ``spec.record_wakeup`` so the cron-service sweep can fire it + through ``engine.run(..., source="wakeup")``. + """ + session_id = spec.session_id + captured_files: set[str] = set() + + async def _snapshot_hook(hook_input, tool_use_id, context): + """PreToolUse: capture file content before Edit/Write/NotebookEdit.""" + tool_input = hook_input.get("tool_input", {}) + file_path = tool_input.get("file_path") or tool_input.get("notebook_path") + + if file_path and file_path not in captured_files and spec.snapshot: + captured_files.add(file_path) + content = _read_file_safe(file_path) + try: + await spec.snapshot(session_id, file_path, content) + logger.info("Captured file snapshot for %s", file_path) + except Exception as e: + logger.warning( + "Failed to save file snapshot for %s: %s", file_path, e, + ) + + return {"hookSpecificOutput": {"hookEventName": "PreToolUse"}} + + async def _validate_image_hook(hook_input, tool_use_id, context): + """PreToolUse: validate image files before Read. + + The CLI's Read tool detects images by extension and base64- + encodes them into image content blocks. If the file isn't a + valid image, the API rejects it with 400 and the bad block + persists in the CLI's history — an unrecoverable poison loop. + Check magic bytes and size *before* Read executes. + """ + tool_input = hook_input.get("tool_input", {}) + file_path = tool_input.get("file_path", "") + + error = validate_image_file(file_path) + if error: + logger.warning( + "Blocked Read of invalid image for session %s: %s", + session_id[:8], error, + ) + return { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": error, + }, + } + + return {"hookSpecificOutput": {"hookEventName": "PreToolUse"}} + + async def _capture_wakeup_hook(hook_input, tool_use_id, context): + """PostToolUse: record a ScheduleWakeup so Nerve can fire it.""" + if spec.record_wakeup: + try: + await spec.record_wakeup( + session_id, hook_input.get("tool_input", {}) or {}, + ) + except Exception as e: + logger.warning( + "Failed to record wakeup for session %s: %s", + session_id, e, + ) + return {"hookSpecificOutput": {"hookEventName": "PostToolUse"}} + + async def _grant_permission_hook(hook_input, tool_use_id, context): + """PreToolUse: pre-approve non-interactive tools. + + Background sub-agents run detached and non-blocking, so the + CLI never invokes ``can_use_tool`` for their nested tool + calls and denies Write/Edit/Bash by default. A PreToolUse + hook DOES fire for them, so returning + ``permissionDecision: "allow"`` grants the same auto-approval + foreground agents get. Interactive tools and Read are left + untouched (they defer to ``can_use_tool`` / the validator). + """ + tool_name = hook_input.get("tool_name", "") + if tool_name in INTERACTIVE_TOOLS or tool_name == "Read": + return {"hookSpecificOutput": {"hookEventName": "PreToolUse"}} + return { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "allow", + "permissionDecisionReason": ( + "nerve: auto-approved (background-agent permission parity)" + ), + } + } + + pre_tool_use = [ + HookMatcher(matcher="Edit|Write|NotebookEdit", hooks=[_snapshot_hook]), + HookMatcher(matcher="Read", hooks=[_validate_image_hook]), + ] + # Catch-all permission grant so background sub-agents inherit the + # foreground's tool permissions. Registered last so the snapshot/ + # validator hooks still run for their tools; a deny from the + # validator wins over this allow. + if self.config.agent.background_agent_permissions: + pre_tool_use.append( + HookMatcher(matcher=None, hooks=[_grant_permission_hook]) + ) + + return { + "PreToolUse": pre_tool_use, + "PostToolUse": [ + HookMatcher(matcher="ScheduleWakeup", hooks=[_capture_wakeup_hook]), + ], + } + + # -- thinking / effort helpers (moved verbatim from engine) ---------- # + + @staticmethod + def _model_supports_legacy_enabled_thinking(model: str | None) -> bool: + # Claude 4.5 / 4.6 accept thinking.type="enabled" with budget_tokens. + # Newer models (4.7+) require thinking.type="adaptive" with effort. + if not model: + return False + m = model.lower() + return "4-5" in m or "4-6" in m + + @staticmethod + def _parse_thinking_config(value: str, model: str | None = None) -> dict | None: + """Parse thinking config string into SDK ThinkingConfig dict.""" + v = value.strip().lower() + if v == "disabled": + return {"type": "disabled"} + if v == "adaptive": + return {"type": "adaptive"} + if not ClaudeBackend._model_supports_legacy_enabled_thinking(model): + return {"type": "adaptive"} + budget_map = { + "max": 128_000, + "high": 64_000, + "medium": 32_000, + "low": 8_000, + } + if v in budget_map: + return {"type": "enabled", "budget_tokens": budget_map[v]} + try: + tokens = int(v) + return {"type": "enabled", "budget_tokens": tokens} + except ValueError: + logger.warning("Unknown thinking config '%s', using adaptive", value) + return {"type": "adaptive"} + + # Effort levels accepted per Claude model — substring-matched against the + # full model name so dated aliases (e.g. "claude-opus-4-8-20260528") resolve. + # Ordered most-specific to least-specific; first match wins. Mirrors the + # pattern used by MODEL_PRICING in nerve/db/usage.py. + _MODEL_EFFORT_LEVELS: dict[str, tuple[str, ...]] = { + "fable-5": ("low", "medium", "high", "xhigh", "max"), + "opus-4-8": ("low", "medium", "high", "xhigh", "max"), + "opus-4-7": ("low", "medium", "high", "xhigh", "max"), + "opus-4-6": ("low", "medium", "high", "max"), + "sonnet-4-6": ("low", "medium", "high"), + } + _EFFORT_RANK: tuple[str, ...] = ("low", "medium", "high", "xhigh", "max") + + @staticmethod + def _effective_effort(value: str, model: str | None = None) -> str | None: + """Return ``value`` capped to the highest effort level ``model`` supports.""" + if value not in ClaudeBackend._EFFORT_RANK: + return None + allowed: tuple[str, ...] | None = None + if model: + m = model.lower() + for key, levels in ClaudeBackend._MODEL_EFFORT_LEVELS.items(): + if key in m: + allowed = levels + break + if not allowed or value in allowed: + return value + requested_rank = ClaudeBackend._EFFORT_RANK.index(value) + for level in reversed(ClaudeBackend._EFFORT_RANK[: requested_rank + 1]): + if level in allowed: + logger.debug( + "Capped effort %r to %r for model %r (model caps at %r)", + value, level, model, allowed[-1], + ) + return level + return None + + +# ------------------------------------------------------------------ # +# Client # +# ------------------------------------------------------------------ # + +class ClaudeClient(AgentClient): + """One live Claude Code CLI subprocess for one nerve session.""" + + def __init__(self, spec: SessionSpec, options: ClaudeAgentOptions): + self._spec = spec + self._options = options + self._sdk = ClaudeSDKClient(options=options) + self._native_session_id: str | None = spec.resume_native_id + # The resolved model this client was built with (engine reads it + # to detect mid-session model switches). + self.model: str = options.model or "" + + # -- protocol ------------------------------------------------------- # + + @property + def native_session_id(self) -> str | None: + return self._native_session_id + + async def connect(self) -> None: + await self._sdk.connect() + + async def start_turn(self, turn: TurnInput) -> None: + try: + if turn.images or turn.documents: + blocks = self._build_content_blocks(turn) + + async def _prompt(): + yield { + "type": "user", + "message": {"role": "user", "content": blocks}, + "parent_tool_use_id": None, + } + + await self._sdk.query(_prompt()) + else: + await self._sdk.query(self._escape_slash(turn.text)) + except CLIConnectionError as e: + raise TransportDiedError(str(e)) from e + + @staticmethod + def _escape_slash(text: str) -> str: + # Escape slash-prefixed messages so Claude Code CLI doesn't + # intercept them as built-in slash commands. Registered bot + # commands (/stop, /new, ...) are handled upstream — anything + # that reaches here should go straight to the LLM. + if text and text.startswith("/"): + return "​" + text + return text + + def _build_content_blocks(self, turn: TurnInput) -> list[dict[str, Any]]: + """Build Anthropic multi-modal content blocks (moved from engine).""" + blocks: list[dict[str, Any]] = [] + text = self._escape_slash(turn.text) + if text: + blocks.append({"type": "text", "text": text}) + for img in (turn.images or []) + (turn.documents or []): + # Text files are inlined as text context blocks + if img.get("type") == "text_file": + fname = img.get("filename", "file") + content = img.get("content", "") + blocks.append({ + "type": "text", + "text": f"--- Attached: {fname} ---\n{content}", + }) + continue + + # PDFs use "document" content block; images use "image" + block_type = ( + "document" if img.get("media_type") == "application/pdf" + else "image" + ) + + # Validate image data before sending — prevent poisoning the + # CLI's conversation with unprocessable images. + if block_type == "image": + img_error = validate_image_data( + img.get("data", ""), img.get("media_type", ""), + ) + if img_error: + logger.warning( + "Skipping invalid image for session %s: %s", + self._spec.session_id[:8], img_error, + ) + # Inject as text so the agent knows what happened + blocks.append({ + "type": "text", + "text": f"[Image skipped: {img_error}]", + }) + continue + + blocks.append({ + "type": block_type, + "source": { + "type": img.get("type", "base64"), + "media_type": img.get("media_type"), + "data": img.get("data"), + }, + }) + return blocks + + async def receive_turn(self) -> AsyncIterator[ev.AgentEvent]: + """Iterate the SDK response with a per-message idle timeout. + + ``receive_response()`` can block indefinitely if the CLI hangs + (stuck API request, broken stdio pipe). Each ``__anext__()`` is + wrapped in ``asyncio.wait_for`` so a silent CLI raises + ``asyncio.TimeoutError`` into the engine's hung-client retry + path. The timeout is per-message: long tool calls don't trip it + as long as tool_use/tool_result chunks keep arriving. + ``idle_timeout <= 0`` disables the timeout. + """ + idle_timeout = self._spec.idle_timeout + response_iter = self._sdk.receive_response() + try: + while True: + try: + if idle_timeout and idle_timeout > 0: + message = await asyncio.wait_for( + response_iter.__anext__(), timeout=idle_timeout, + ) + else: + message = await response_iter.__anext__() + except StopAsyncIteration: + return + except asyncio.TimeoutError: + logger.warning( + "CLI idle timeout (%ds) for session %s — no SDK " + "message received; treating CLI as hung", + idle_timeout, self._spec.session_id, + ) + raise + done = False + for event in self._translate_and_capture(message): + if isinstance(event, ev.TurnCompleted): + done = True + yield event + if done: + return + finally: + with contextlib.suppress(Exception): + await response_iter.aclose() + + def _translate_and_capture(self, message: Any) -> list[ev.AgentEvent]: + # Early-capture the SDK session id from any message that carries + # it, so /stop-mid-turn persistence works before a ResultMessage + # ever arrives (the engine reads client.native_session_id). + msg_sid = getattr(message, "session_id", None) + if msg_sid: + self._native_session_id = msg_sid + return translate_message(message) + + async def interrupt(self) -> None: + await self._sdk.interrupt() + + def is_alive(self) -> bool: + transport = getattr(self._sdk, "_transport", None) + if not transport: + return False + process = getattr(transport, "_process", None) + if process is None: + return False + return process.returncode is None + + async def disconnect(self, timeout: float = 5.0) -> None: + """Disconnect without risking an event-loop spin (moved from + engine._safe_disconnect). + + The SDK's Query.close() cancels its anyio task group before + closing the transport. If any task inside that group cannot exit + promptly, the anyio _deliver_cancellation callback spins at 100% + CPU forever. Strategy: kill the subprocess first so every I/O + wait unblocks, try a clean disconnect with a timeout, then + forcibly disarm the task group if needed. + """ + client = self._sdk + # --- 1. Kill subprocess immediately --- + transport = getattr(getattr(client, "_query", None), "transport", None) + proc = getattr(transport, "_process", None) + if proc is not None and proc.returncode is None: + try: + proc.kill() + except Exception: + pass + + # --- 2. Try a clean disconnect with a timeout --- + try: + await asyncio.wait_for(client.disconnect(), timeout=timeout) + return + except asyncio.TimeoutError: + logger.warning( + "SDK client disconnect timed out after %.1fs — " + "force-clearing task group to stop _deliver_cancellation spin", + timeout, + ) + except Exception: + pass + + # --- 3. Forcibly disarm the stuck task group --- + query = getattr(client, "_query", None) + if query is None: + return + tg = getattr(query, "_tg", None) + if tg is None: + return + + cs = getattr(tg, "cancel_scope", None) + handle = getattr(cs, "_cancel_handle", None) + if handle is not None: + handle.cancel() + cs._cancel_handle = None + + if cs is not None: + cs._tasks.clear() + tg._tasks.clear() + + try: + await asyncio.wait_for(query.transport.close(), timeout=2.0) + except Exception: + pass + + client._query = None + client._transport = None + + # -- idle stream (autonomous CLI turns between run() calls) --------- # + + def _message_stream(self) -> Any | None: + """The SDK client's internal receive stream. + + Private-API access (``client._query._message_receive``), pinned + to the bundled SDK version. Callers degrade gracefully (drain and + watcher become no-ops) when the attribute shape changes. + """ + return getattr(getattr(self._sdk, "_query", None), "_message_receive", None) + + def buffer_used(self) -> int: + stream = self._message_stream() + if stream is None: + return 0 + try: + return int(stream.statistics().current_buffer_used) + except Exception: + return 0 + + def try_receive_idle_events(self) -> list[ev.AgentEvent] | None: + """Non-parking probe: translated events of one buffered message. + + Returns ``None`` when nothing is buffered or the stream is + closed; ``[]`` for messages that parse to nothing (skip and call + again). + """ + import anyio + + stream = self._message_stream() + if stream is None: + return None + try: + data = stream.receive_nowait() + except anyio.WouldBlock: + return None + except (anyio.EndOfStream, anyio.ClosedResourceError): + return None + except Exception: + return None + return self._parse_idle_payload(data) + + async def receive_idle_events( + self, timeout: float | None, + ) -> list[ev.AgentEvent] | None: + """Park up to ``timeout`` seconds for the next idle message. + + Returns ``None`` when the stream ended/closed; raises + ``asyncio.TimeoutError`` on timeout (caller applies hung-CLI + treatment); ``[]`` for skip-and-continue payloads. + """ + import anyio + + stream = self._message_stream() + if stream is None: + return None + try: + data = await asyncio.wait_for(stream.receive(), timeout=timeout) + except (anyio.EndOfStream, anyio.ClosedResourceError): + return None + return self._parse_idle_payload(data) + + def _parse_idle_payload(self, data: Any) -> list[ev.AgentEvent] | None: + """Raw stream payload → events. ``None`` = stream over.""" + from claude_agent_sdk._errors import MessageParseError + from claude_agent_sdk._internal.message_parser import parse_message + + mtype = data.get("type") if isinstance(data, dict) else None + if mtype == "end": + # Reader sentinel — stream is closed. + return None + if mtype == "error": + logger.error( + "SDK stream error during idle drain for %s: %s", + self._spec.session_id, data.get("error"), + ) + return None + + try: + message = parse_message(data) + except MessageParseError as pe: + logger.warning( + "Unparseable SDK message during drain for %s: %s", + self._spec.session_id, pe, + ) + return [] + if message is None: + return [] + return self._translate_and_capture(message) diff --git a/nerve/agent/backends/codex/__init__.py b/nerve/agent/backends/codex/__init__.py new file mode 100644 index 0000000..f3ae89d --- /dev/null +++ b/nerve/agent/backends/codex/__init__.py @@ -0,0 +1,19 @@ +"""OpenAI Codex backend package.""" + +from nerve.agent.backends.codex.backend import ( + CodexBackend, + CodexClient, + CodexTurnError, +) +from nerve.agent.backends.codex.appserver import ( + CodexAppServerClient, + CodexRpcError, +) + +__all__ = [ + "CodexAppServerClient", + "CodexBackend", + "CodexClient", + "CodexRpcError", + "CodexTurnError", +] diff --git a/nerve/agent/backends/codex/appserver.py b/nerve/agent/backends/codex/appserver.py new file mode 100644 index 0000000..b39fe5c --- /dev/null +++ b/nerve/agent/backends/codex/appserver.py @@ -0,0 +1,383 @@ +"""Asyncio JSON-RPC 2.0 client for ``codex app-server`` over stdio. + +Why not the official ``openai-codex`` SDK: its reader loop dispatches +server-initiated requests (approvals) *synchronously on the reader +thread* — a blocking approval handler stalls all message routing, +including the ``turn/interrupt`` response, deadlocking the client — and +its async wrapper accepts no approval handler at all (verified against +0.1.0b2; see docs/plans/codex-backend.md §0/§6). Nerve's approvals wait +on user input for up to an hour, so server requests here are dispatched +as independent asyncio tasks: the reader never blocks, deltas keep +flowing while an approval is pending, and interrupts stay responsive. + +Protocol shapes verified against the schema exported from codex-cli +0.144.1 (``codex app-server generate-json-schema``); see +``tests/fixtures/codex_schema_meta.json``. Parsing is defensive: +unknown notifications are debug-logged, unknown server requests get a +safe response, missing fields never raise. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import json +import logging +from typing import Any, Awaitable, Callable + +from nerve.agent.backends.base import TransportDiedError + +logger = logging.getLogger(__name__) + +# async (method, params) -> result dict for the server's request +ServerRequestHandler = Callable[[str, dict], Awaitable[dict]] + +# Notification surfaces nerve never consumes — suppressed at initialize +# so the reader doesn't churn through them. +_OPT_OUT_NOTIFICATIONS = [ + "thread/realtime/started", + "thread/realtime/closed", + "thread/realtime/error", + "thread/realtime/itemAdded", + "thread/realtime/outputAudio/delta", + "thread/realtime/sdp", + "thread/realtime/transcriptDelta", + "thread/realtime/transcriptDone", + "fuzzyFileSearch/sessionUpdated", + "fuzzyFileSearch/sessionCompleted", +] + +_STDERR_TAIL_LINES = 40 + + +class CodexAppServerClient: + """One ``codex app-server`` subprocess speaking JSONL JSON-RPC.""" + + def __init__( + self, + *, + bin_path: str, + cwd: str, + env: dict[str, str], + server_request_handler: ServerRequestHandler, + config_overrides: list[str] | None = None, + client_name: str = "nerve", + client_version: str = "1.0.0", + request_timeout: float = 60.0, + ) -> None: + self._bin_path = bin_path + self._cwd = cwd + self._env = env + self._config_overrides = list(config_overrides or []) + self._client_name = client_name + self._client_version = client_version + self._request_timeout = request_timeout + self._on_server_request = server_request_handler + + self._proc: asyncio.subprocess.Process | None = None + self._reader_task: asyncio.Task | None = None + self._stderr_task: asyncio.Task | None = None + self._server_request_tasks: set[asyncio.Task] = set() + self._write_lock = asyncio.Lock() + self._next_id = 0 + self._pending: dict[Any, asyncio.Future] = {} + # Bounded so a notification storm while nobody is consuming can't + # grow without limit; the consumer is always attached during turns. + self.notifications: asyncio.Queue[dict] = asyncio.Queue(maxsize=4096) + self._stderr_tail: list[str] = [] + self._closed = False + + # -- lifecycle ------------------------------------------------------ # + + async def start(self) -> dict: + """Spawn the subprocess and run the ``initialize`` handshake. + + Returns the ``initialize`` response payload. + """ + args = [self._bin_path] + for kv in self._config_overrides: + args.extend(["--config", kv]) + args.extend(["app-server", "--listen", "stdio://"]) + + try: + self._proc = await asyncio.create_subprocess_exec( + *args, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=self._cwd, + env=self._env, + ) + except (OSError, ValueError) as e: + raise TransportDiedError( + f"Failed to spawn codex app-server ({self._bin_path}): {e}" + ) from e + + self._reader_task = asyncio.create_task( + self._reader_loop(), name=f"codex-reader:{self._proc.pid}", + ) + self._stderr_task = asyncio.create_task( + self._stderr_loop(), name=f"codex-stderr:{self._proc.pid}", + ) + + response = await self.request("initialize", { + "clientInfo": { + "name": self._client_name, + "title": "Nerve", + "version": self._client_version, + }, + "capabilities": { + "optOutNotificationMethods": _OPT_OUT_NOTIFICATIONS, + }, + }) + await self.notify("initialized", None) + return response + + def is_alive(self) -> bool: + return ( + not self._closed + and self._proc is not None + and self._proc.returncode is None + ) + + async def close(self) -> None: + """Terminate the subprocess and fail everything pending.""" + self._closed = True + for task in (self._reader_task, self._stderr_task): + if task is not None and not task.done(): + task.cancel() + for task in list(self._server_request_tasks): + task.cancel() + + proc, self._proc = self._proc, None + if proc is not None: + with contextlib.suppress(ProcessLookupError): + proc.terminate() + try: + await asyncio.wait_for(proc.wait(), timeout=2.0) + except (asyncio.TimeoutError, ProcessLookupError): + with contextlib.suppress(ProcessLookupError): + proc.kill() + with contextlib.suppress(Exception): + await asyncio.wait_for(proc.wait(), timeout=2.0) + + self._fail_pending(TransportDiedError("codex app-server closed")) + + # -- JSON-RPC ------------------------------------------------------- # + + async def request( + self, method: str, params: dict | None = None, + timeout: float | None = None, + ) -> dict: + """Send a request; await and return its ``result``. + + Raises :class:`CodexRpcError` on a JSON-RPC error response and + :class:`TransportDiedError` when the subprocess dies first. + """ + if not self.is_alive(): + raise TransportDiedError( + f"codex app-server is not running{self._stderr_hint()}" + ) + self._next_id += 1 + req_id = self._next_id + future: asyncio.Future = asyncio.get_running_loop().create_future() + self._pending[req_id] = future + try: + await self._write({ + "jsonrpc": "2.0", "id": req_id, + "method": method, "params": params or {}, + }) + return await asyncio.wait_for( + future, timeout=timeout or self._request_timeout, + ) + except asyncio.TimeoutError: + raise TransportDiedError( + f"codex app-server did not answer {method} within " + f"{timeout or self._request_timeout:.0f}s{self._stderr_hint()}" + ) from None + finally: + self._pending.pop(req_id, None) + + async def notify(self, method: str, params: dict | None) -> None: + payload: dict[str, Any] = {"jsonrpc": "2.0", "method": method} + if params is not None: + payload["params"] = params + await self._write(payload) + + async def _write(self, payload: dict) -> None: + proc = self._proc + if proc is None or proc.stdin is None: + raise TransportDiedError("codex app-server stdin is closed") + line = json.dumps(payload, ensure_ascii=False) + "\n" + async with self._write_lock: + try: + proc.stdin.write(line.encode("utf-8")) + await proc.stdin.drain() + except (BrokenPipeError, ConnectionResetError, OSError) as e: + raise TransportDiedError( + f"codex app-server pipe broken: {e}{self._stderr_hint()}" + ) from e + + # -- reader --------------------------------------------------------- # + + async def _reader_loop(self) -> None: + proc = self._proc + if proc is None or proc.stdout is None: + return + try: + while True: + line = await proc.stdout.readline() + if not line: + break # EOF — process died or closed stdout + line = line.strip() + if not line: + continue + try: + msg = json.loads(line) + except ValueError: + logger.warning( + "codex app-server emitted non-JSON line: %.200s", line, + ) + continue + if not isinstance(msg, dict): + continue + self._dispatch(msg) + except asyncio.CancelledError: + raise + except Exception as e: # pragma: no cover - defensive + logger.error("codex app-server reader crashed: %s", e, exc_info=True) + finally: + self._fail_pending(TransportDiedError( + f"codex app-server stream ended{self._stderr_hint()}" + )) + + def _dispatch(self, msg: dict) -> None: + has_method = "method" in msg + has_id = "id" in msg + + if has_method and has_id: + # Server-initiated request (approval, user input, ...). + # Dispatch as an independent task so a long user wait never + # blocks the reader (this is the whole reason this client + # exists — see module docstring). + task = asyncio.create_task( + self._answer_server_request(msg), + name=f"codex-server-request:{msg.get('method')}", + ) + self._server_request_tasks.add(task) + task.add_done_callback(self._server_request_tasks.discard) + return + + if has_method: + # Notification. + method = msg.get("method") + params = msg.get("params") + note = { + "method": method if isinstance(method, str) else "", + "params": params if isinstance(params, dict) else {}, + } + try: + self.notifications.put_nowait(note) + except asyncio.QueueFull: + # Keep the newest — drop the oldest buffered notification. + with contextlib.suppress(asyncio.QueueEmpty): + self.notifications.get_nowait() + with contextlib.suppress(asyncio.QueueFull): + self.notifications.put_nowait(note) + return + + # Response to one of our requests. + req_id = msg.get("id") + future = self._pending.get(req_id) + if future is None or future.done(): + logger.debug("codex app-server response for unknown id %r", req_id) + return + if "error" in msg and msg["error"] is not None: + err = msg["error"] or {} + future.set_exception(CodexRpcError( + code=int(err.get("code") or 0), + message=str(err.get("message") or "unknown error"), + data=err.get("data"), + )) + else: + result = msg.get("result") + future.set_result(result if isinstance(result, dict) else {}) + + async def _answer_server_request(self, msg: dict) -> None: + method = msg.get("method") + params = msg.get("params") + req_id = msg.get("id") + method_str = method if isinstance(method, str) else "" + params_dict = params if isinstance(params, dict) else {} + try: + result = await self._on_server_request(method_str, params_dict) + except asyncio.CancelledError: + raise + except Exception as e: + logger.error( + "Server-request handler failed for %s: %s", method_str, e, + exc_info=True, + ) + with contextlib.suppress(Exception): + await self._write({ + "jsonrpc": "2.0", "id": req_id, + "error": {"code": -32603, "message": str(e)[:500]}, + }) + return + with contextlib.suppress(TransportDiedError): + await self._write({ + "jsonrpc": "2.0", "id": req_id, + "result": result if isinstance(result, dict) else {}, + }) + + async def _stderr_loop(self) -> None: + proc = self._proc + if proc is None or proc.stderr is None: + return + try: + while True: + line = await proc.stderr.readline() + if not line: + break + text = line.decode("utf-8", errors="replace").rstrip() + if not text: + continue + self._stderr_tail.append(text) + if len(self._stderr_tail) > _STDERR_TAIL_LINES: + del self._stderr_tail[0] + lowered = text.lower() + if "error" in lowered or "panic" in lowered: + logger.warning("codex stderr: %s", text) + else: + logger.debug("codex stderr: %s", text) + except asyncio.CancelledError: + raise + except Exception: # pragma: no cover - defensive + pass + + # -- helpers -------------------------------------------------------- # + + def _fail_pending(self, error: BaseException) -> None: + for future in self._pending.values(): + if not future.done(): + future.set_exception(error) + self._pending.clear() + # Wake a parked notification consumer so it observes the death + # instead of waiting forever. + with contextlib.suppress(asyncio.QueueFull): + self.notifications.put_nowait({"method": "__transport_died__", "params": {}}) + + def _stderr_hint(self) -> str: + if not self._stderr_tail: + return "" + return " | stderr tail: " + " / ".join(self._stderr_tail[-5:])[:800] + + +class CodexRpcError(Exception): + """JSON-RPC error response from the app-server.""" + + def __init__(self, code: int, message: str, data: Any = None): + super().__init__(f"codex rpc error {code}: {message}") + self.code = code + self.message = message + self.data = data diff --git a/nerve/agent/backends/codex/backend.py b/nerve/agent/backends/codex/backend.py new file mode 100644 index 0000000..7e43a3e --- /dev/null +++ b/nerve/agent/backends/codex/backend.py @@ -0,0 +1,884 @@ +"""Codex backend — OpenAI Codex (``codex app-server``) behind the seam. + +One app-server subprocess per nerve session (mirroring the Claude +process model, so idle-sweep / kill / rebuild semantics carry over); one +Codex *thread* per nerve session; one turn at a time (the engine +serializes turns per session). + +Event mapping (docs/plans/codex-backend.md §7) deliberately reuses the +Claude tool vocabulary ("Bash" / "Edit" / "WebSearch" / ``mcp__*``) so +the existing UI — tool chips, the file-diff panel keyed on +``input.file_path``, snapshot-based diffs — works unchanged. Inputs +carry codex-native fields; this is presentation, not a semantic lie. + +Nerve tools reach codex sessions through the gateway's Streamable HTTP +MCP endpoint with a session-bound bearer token (env +``NERVE_MCP_TOKEN``), so ``notify`` / ``ask_user`` / ``memorize`` / ... +attribute to the real session exactly like the in-process Claude MCP. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import os +from pathlib import Path +from typing import Any, AsyncIterator + +from nerve.agent.backends import events as ev +from nerve.agent.backends.base import ( + AgentClient, + BackendCapabilities, + BackendError, + SessionSpec, + TransportDiedError, + TurnInput, +) +from nerve.agent.backends.codex.appserver import ( + CodexAppServerClient, + CodexRpcError, +) +from nerve.agent.backends.codex.pricing import compute_cost +from nerve.agent.backends.images import validate_image_data + +logger = logging.getLogger(__name__) + +# Backend notes appended to the developer instructions so the model +# knows how this runtime differs from the docs it may have absorbed. +_BACKEND_NOTES = """ + + +You are running on Nerve's Codex backend. +- Nerve's tools (memorize, memory_recall, task_*, notify, ask_user, skills, + schedule_wakeup, ...) are provided by the `nerve` MCP server — call them as + `mcp__nerve__` / however your harness names MCP tools. +- To schedule a future wakeup of this session, use the `schedule_wakeup` nerve + tool (there is no ScheduleWakeup built-in here). +- AskUserQuestion and plan mode do not exist in this runtime. To ask the user + something, use the nerve `ask_user` tool. + +""" + + +class CodexTurnError(BackendError): + """A codex turn failed with a non-retryable error.""" + + +def _toml_str(value: str) -> str: + """Quote a string as a TOML literal for ``-c key=value`` overrides.""" + return json.dumps(value, ensure_ascii=False) + + +class CodexBackend: + """OpenAI Codex app-server backend.""" + + name = "codex" + capabilities = BackendCapabilities( + cost_is_cumulative=False, + supports_idle_stream=False, + supports_cache_ttl=False, + interactive_builtins=False, + reports_context_window=True, + ) + + def __init__(self, deps: Any): + self._deps = deps + self.config = deps.config + self.codex = deps.config.codex + Path(self._home_dir()).mkdir(parents=True, exist_ok=True) + + # -- policy ---------------------------------------------------------- # + + def default_model(self, source: str) -> str: + if source in ("cron", "hook") and self.codex.cron_model: + return self.codex.cron_model + return self.codex.model + + def excluded_tools(self) -> set[str]: + return set() + + def validate_resume_target(self, native_id: str, cwd: str) -> bool: + # No cheap filesystem check for codex threads; create_client + # recovers from a stale id via ResumeDroppedError instead. + return True + + def _home_dir(self) -> str: + return os.path.expanduser(self.codex.home_dir) + + # -- client construction --------------------------------------------- # + + async def create_client(self, spec: SessionSpec) -> "CodexClient": + client = CodexClient(self, spec) + await client.connect() + return client + + # -- config assembly (used by CodexClient) --------------------------- # + + def build_env(self, spec: SessionSpec) -> dict[str, str]: + env = os.environ.copy() + env["CODEX_HOME"] = self._home_dir() + # Session-bound bearer token for the nerve MCP endpoint — + # referenced from the MCP config via bearer_token_env_var so the + # token never lands in any file. + if self._deps.mint_session_token is not None: + try: + env["NERVE_MCP_TOKEN"] = self._deps.mint_session_token( + spec.session_id, + ) + except Exception as e: + logger.warning( + "Could not mint MCP session token for %s: %s", + spec.session_id, e, + ) + return env + + def build_config_overrides(self, spec: SessionSpec) -> list[str]: + """``-c key=value`` process-level config overrides. + + Process == session here, so spawn-level overrides ARE per-session + config. This is the same mechanism the official SDKs use and is + honored for every thread the process hosts. + """ + overrides: list[str] = [ + # The workspace AGENTS.md is Nerve's identity bundle and is + # already injected via developerInstructions — suppress + # project-doc discovery so it isn't duplicated. + "project_doc_max_bytes=0", + ] + + # Nerve tools over the gateway's Streamable HTTP MCP endpoint. + port = self._deps.gateway_port() + if port and self._deps.mint_session_token is not None: + base = "mcp_servers.nerve" + url = f"http://127.0.0.1:{port}/mcp/v1" + overrides += [ + f"{base}.url={_toml_str(url)}", + f"{base}.bearer_token_env_var={_toml_str('NERVE_MCP_TOKEN')}", + f"{base}.tool_timeout_sec={int(self.codex.tool_timeout_sec)}", + ] + else: + logger.warning( + "Codex session %s starts WITHOUT nerve tools: gateway port " + "or token minter unavailable", spec.session_id, + ) + + # External MCP servers (grafana, langfuse, ...) — translate the + # nerve config into codex's mcp_servers shape. Claude-plugin MCPs + # have no codex equivalent and are skipped (docs plan §14). + for srv in self._deps.external_mcp_servers(): + if not srv.enabled or srv.name == "nerve": + continue + try: + overrides += self._translate_mcp_server(srv) + except Exception as e: + logger.warning( + "Skipping MCP server %r for codex: %s", srv.name, e, + ) + + if not self.codex.web_search: + overrides.append("tools.web_search=false") + + for key, value in (self.codex.extra_config or {}).items(): + if isinstance(value, str): + overrides.append(f"{key}={_toml_str(value)}") + elif isinstance(value, bool): + overrides.append(f"{key}={'true' if value else 'false'}") + else: + overrides.append(f"{key}={value}") + return overrides + + @staticmethod + def _translate_mcp_server(srv: Any) -> list[str]: + """Translate one nerve ``McpServerConfig`` into codex overrides.""" + base = f"mcp_servers.{srv.name}" + out: list[str] = [] + command = getattr(srv, "command", None) + url = getattr(srv, "url", None) + if command: + out.append(f"{base}.command={_toml_str(command)}") + args = getattr(srv, "args", None) or [] + if args: + arr = ", ".join(_toml_str(str(a)) for a in args) + out.append(f"{base}.args=[{arr}]") + env = getattr(srv, "env", None) or {} + for k, v in env.items(): + out.append(f"{base}.env.{k}={_toml_str(str(v))}") + elif url: + out.append(f"{base}.url={_toml_str(url)}") + headers = getattr(srv, "headers", None) or {} + for k, v in headers.items(): + out.append(f'{base}.http_headers.{_toml_str(k)}={_toml_str(str(v))}') + else: + raise ValueError("no command or url") + return out + + def thread_params(self, spec: SessionSpec) -> dict[str, Any]: + params: dict[str, Any] = { + "cwd": spec.cwd, + "model": spec.model or self.codex.model, + "sandbox": self.codex.sandbox, + "approvalPolicy": self.codex.approval_policy, + "developerInstructions": spec.system_prompt + _BACKEND_NOTES, + } + return params + + def map_effort(self, effort: str) -> str | None: + return (self.codex.effort_map or {}).get(effort) + + def auth_hint(self) -> str: + if self.codex.auth == "api_key": + return ( + "configure codex.api_key (or codex.api_key_env) in config" + ) + return f"run: CODEX_HOME={self._home_dir()} codex login" + + def resolve_api_key(self) -> str | None: + if self.codex.api_key: + return self.codex.api_key + # Fall back to the top-level secret (config.local.yaml) nerve + # already keeps for OpenAI, then the configured env var. + if getattr(self.config, "openai_api_key", ""): + return self.config.openai_api_key + if self.codex.api_key_env: + return os.environ.get(self.codex.api_key_env) or None + return None + + +class CodexClient(AgentClient): + """One live ``codex app-server`` subprocess for one nerve session.""" + + def __init__(self, backend: CodexBackend, spec: SessionSpec): + self._backend = backend + self._spec = spec + self._transport = CodexAppServerClient( + bin_path=backend.codex.bin_path, + cwd=spec.cwd, + env=backend.build_env(spec), + server_request_handler=self._handle_server_request, + config_overrides=backend.build_config_overrides(spec), + ) + self._thread_id: str | None = None + self._turn_id: str | None = None + self._resume_dropped = False + # Serving model as resolved for this thread; updated on + # model/rerouted notifications. + self.model: str = spec.model or backend.codex.model + # item id -> item payload from item/started (approval correlation + # + fileChange fan-out bookkeeping). + self._items: dict[str, dict] = {} + # Latest thread/tokenUsage/updated for the active turn. + self._turn_usage: dict | None = None + self._context_window: int | None = None + + # -- protocol --------------------------------------------------------- # + + @property + def native_session_id(self) -> str | None: + return self._thread_id + + @property + def resume_dropped(self) -> bool: + """True when the stored thread id could not be resumed and a + fresh thread was started — the engine must clear the persisted + native id (it re-persists the new one at turn end).""" + return self._resume_dropped + + async def connect(self) -> None: + await self._transport.start() + await self._ensure_auth() + + spec = self._spec + backend = self._backend + params = backend.thread_params(spec) + + try: + if spec.resume_native_id and spec.fork: + response = await self._transport.request( + "thread/fork", + {**params, "threadId": spec.resume_native_id}, + ) + elif spec.resume_native_id: + response = await self._transport.request( + "thread/resume", + {**params, "threadId": spec.resume_native_id}, + ) + else: + response = await self._transport.request("thread/start", params) + except CodexRpcError as e: + # Resume-miss recovery: a wiped ~/.nerve/codex/sessions (or a + # rollout the app-server refuses) must never brick the + # session — fall back to a fresh thread and tell the engine + # the old id was dropped. + if not spec.resume_native_id: + raise + logger.warning( + "Codex %s of %s failed for session %s (%s) — starting a " + "fresh thread", + "fork" if spec.fork else "resume", + spec.resume_native_id[:12], spec.session_id, e, + ) + self._resume_dropped = True + response = await self._transport.request("thread/start", params) + + thread = response.get("thread") if isinstance(response, dict) else None + thread_id = (thread or {}).get("id") if isinstance(thread, dict) else None + if not thread_id: + raise BackendError( + f"codex thread/start returned no thread id: {response!r}" + ) + self._thread_id = str(thread_id) + + async def _ensure_auth(self) -> None: + """Best-effort auth check with a clear operator hint. + + An api-key config logs in automatically (persisted in + CODEX_HOME/auth.json); ChatGPT auth must be done once manually. + """ + backend = self._backend + try: + account = await self._transport.request("account/read", {}) + except CodexRpcError as e: + logger.debug("codex account/read failed (%s) — proceeding", e) + return + if isinstance(account, dict) and account.get("account"): + return + + if backend.codex.auth == "api_key": + api_key = backend.resolve_api_key() + if api_key: + try: + await self._transport.request( + "account/login/start", + {"type": "apiKey", "apiKey": api_key}, + ) + logger.info("Codex: logged in with API key") + return + except CodexRpcError as e: + raise BackendError( + f"Codex API-key login failed: {e}" + ) from e + raise BackendError( + "Codex is not authenticated. To fix: " + backend.auth_hint() + ) + + async def start_turn(self, turn: TurnInput) -> None: + if not self._thread_id: + raise BackendError("codex client has no thread") + self._turn_usage = None + self._items.clear() + # Drain notifications that straggled in after the previous turn + # (late tokenUsage updates etc.) so they can't bleed into this one. + while not self._transport.notifications.empty(): + try: + self._transport.notifications.get_nowait() + except Exception: + break + + params: dict[str, Any] = { + "threadId": self._thread_id, + "input": self._build_input_items(turn), + } + effort = self._backend.map_effort(self._spec.effort) + if effort: + params["effort"] = effort + + response = await self._transport.request("turn/start", params) + turn_obj = response.get("turn") if isinstance(response, dict) else None + self._turn_id = ( + str(turn_obj["id"]) + if isinstance(turn_obj, dict) and turn_obj.get("id") + else None + ) + + def _build_input_items(self, turn: TurnInput) -> list[dict[str, Any]]: + items: list[dict[str, Any]] = [] + text = turn.text or "" + notes: list[str] = [] + + for att in (turn.images or []) + (turn.documents or []): + if att.get("type") == "text_file": + fname = att.get("filename", "file") + content = att.get("content", "") + notes.append(f"--- Attached: {fname} ---\n{content}") + continue + media_type = att.get("media_type") or "" + if media_type == "application/pdf": + # No document input type in the codex protocol — surface + # the degradation instead of silently dropping (plan §14). + notes.append( + "[A PDF attachment could not be delivered: the Codex " + "backend does not support document inputs. Ask the " + "user for the content as text if needed.]" + ) + continue + if att.get("path"): + items.append({"type": "localImage", "path": str(att["path"])}) + continue + data = att.get("data", "") + err = validate_image_data(data, media_type) + if err: + logger.warning( + "Skipping invalid image for session %s: %s", + self._spec.session_id[:8], err, + ) + notes.append(f"[Image skipped: {err}]") + continue + items.append({ + "type": "image", + "url": f"data:{media_type};base64,{data}", + }) + + if notes: + text = "\n\n".join(filter(None, [text] + notes)) + # Text goes first so the model reads the instruction before + # attachments — insert rather than append. + if text: + items.insert(0, {"type": "text", "text": text}) + if not items: + items.append({"type": "text", "text": ""}) + return items + + async def receive_turn(self) -> AsyncIterator[ev.AgentEvent]: + """Yield normalized events until this turn completes. + + Per-notification idle timeout mirrors the Claude path: a silent + app-server raises ``asyncio.TimeoutError`` into the engine's + hung-client retry path. Terminates on ``turn/completed`` + regardless of status (completed / interrupted / failed) so the + /stop flow's graceful wait works. + """ + idle_timeout = self._spec.idle_timeout + # The thread model is serving this turn — surface it for + # serving-model tracking (parity with AssistantMessage.model). + yield ev.ModelObserved(model=self.model) + + while True: + try: + if idle_timeout and idle_timeout > 0: + note = await asyncio.wait_for( + self._transport.notifications.get(), + timeout=idle_timeout, + ) + else: + note = await self._transport.notifications.get() + except asyncio.TimeoutError: + logger.warning( + "codex idle timeout (%ds) for session %s — no " + "notification received; treating app-server as hung", + idle_timeout, self._spec.session_id, + ) + raise + + method = note.get("method", "") + params = note.get("params", {}) or {} + + if method == "__transport_died__": + raise TransportDiedError( + "codex app-server died mid-turn" + ) + + # Scope: drop notifications for other turns (stragglers from + # an interrupted predecessor). Thread-scoped notifications + # (no turnId) pass through. + note_turn = params.get("turnId") + if note_turn and self._turn_id and note_turn != self._turn_id: + logger.debug("codex: dropping stale notification %s", method) + continue + + for event in await self._map_notification(method, params): + yield event + if isinstance(event, ev.TurnCompleted): + return + + # -- notification mapping -------------------------------------------- # + + async def _map_notification( + self, method: str, params: dict, + ) -> list[ev.AgentEvent]: + out: list[ev.AgentEvent] = [] + + if method == "item/agentMessage/delta": + delta = params.get("delta") or params.get("text") or "" + if delta: + out.append(ev.TextDelta(text=str(delta))) + + elif method in ( + "item/reasoning/textDelta", + "item/reasoning/summaryTextDelta", + ): + delta = params.get("delta") or params.get("text") or "" + if delta: + out.append(ev.ThinkingDelta(text=str(delta))) + + elif method == "item/started": + out.extend(await self._map_item_started(params.get("item") or {})) + + elif method == "item/completed": + out.extend(self._map_item_completed(params.get("item") or {})) + + elif method == "thread/tokenUsage/updated": + usage = params.get("tokenUsage") or {} + if isinstance(usage, dict): + self._turn_usage = usage + window = usage.get("modelContextWindow") + if isinstance(window, int) and window > 0: + self._context_window = window + + elif method == "model/rerouted": + new_model = ( + params.get("model") or params.get("toModel") + or params.get("to") + ) + if new_model: + self.model = str(new_model) + out.append(ev.ModelObserved(model=self.model)) + + elif method == "item/plan/delta": + out.append(ev.SystemEvent(subtype="codex_plan", data=params)) + + elif method == "turn/completed": + out.append(self._map_turn_completed(params)) + + elif method == "error": + message = self._error_message(params) + if params.get("willRetry"): + logger.info("codex retryable error: %s", message) + out.append(ev.SystemEvent( + subtype="codex_error", + data={"message": message, "will_retry": True}, + )) + else: + # Non-retryable errors are followed by turn/completed + # (status=failed) — remember the message for it, surface + # a system event meanwhile. + logger.warning("codex error: %s", message) + self._last_error = message + out.append(ev.SystemEvent( + subtype="codex_error", + data={"message": message, "will_retry": False}, + )) + + elif method in ( + "turn/started", + "thread/started", + "item/commandExecution/outputDelta", # final output arrives on item/completed + "item/fileChange/outputDelta", + "item/fileChange/patchUpdated", + "item/mcpToolCall/progress", + "account/rateLimits/updated", + "thread/compacted", + "serverRequest/resolved", + ): + pass # consumed elsewhere / intentionally ignored + + else: + logger.debug("codex: unhandled notification %s", method) + + return out + + _last_error: str | None = None + + @staticmethod + def _error_message(params: dict) -> str: + err = params.get("error") + if isinstance(err, dict): + return str(err.get("message") or err) + return str(err or params.get("message") or "unknown codex error") + + async def _map_item_started(self, item: dict) -> list[ev.AgentEvent]: + item_id = str(item.get("id") or "") + item_type = str(item.get("type") or "") + if item_id: + self._items[item_id] = item + out: list[ev.AgentEvent] = [] + + if item_type == "commandExecution": + out.append(ev.ToolUse( + tool_use_id=item_id or None, + name="Bash", + input={ + "command": self._command_str(item), + "cwd": item.get("cwd"), + }, + )) + elif item_type == "fileChange": + for n, change in enumerate(self._changes(item)): + path = str(change.get("path") or "") + # Best-effort pre-apply snapshot for the diff panel. If + # item/started turns out to fire post-apply in some codex + # version, the fallback is reconstructing the pre-image + # from the unified diff we receive at item/completed + # (docs plan §13) — the smoke script probes this ordering. + if path and self._spec.snapshot: + from nerve.agent.interactive import _read_file_safe + hub = self._spec.interactive + fresh = hub.mark_snapshotted(path) if hub else True + if fresh: + try: + await self._spec.snapshot( + self._spec.session_id, path, + _read_file_safe(path), + ) + except Exception as e: + logger.warning( + "Snapshot failed for %s: %s", path, e, + ) + out.append(ev.ToolUse( + tool_use_id=self._change_id(item_id, n), + name="Edit", + input={ + "file_path": path, + "kind": change.get("kind"), + }, + )) + elif item_type == "mcpToolCall": + out.append(ev.ToolUse( + tool_use_id=item_id or None, + name=self._mcp_tool_name(item), + input=self._mcp_tool_input(item), + )) + elif item_type == "webSearch": + out.append(ev.ToolUse( + tool_use_id=item_id or None, + name="WebSearch", + input={"query": item.get("query", "")}, + )) + elif item_type in ("plan", "planUpdate", "todoList"): + out.append(ev.SystemEvent(subtype="codex_plan", data=item)) + # agentMessage/reasoning items stream via their delta + # notifications; nothing to emit at start. + return out + + def _map_item_completed(self, item: dict) -> list[ev.AgentEvent]: + item_id = str(item.get("id") or "") + item_type = str(item.get("type") or "") + started = self._items.pop(item_id, None) + out: list[ev.AgentEvent] = [] + + if item_type == "commandExecution": + exit_code = item.get("exitCode") + is_error = isinstance(exit_code, int) and exit_code != 0 + output = str(item.get("aggregatedOutput") or "") + if isinstance(exit_code, int): + output = output or "" + output += ("" if not output or output.endswith("\n") else "\n") + output += f"(exit code {exit_code})" if is_error else "" + out.append(ev.ToolResult( + tool_use_id=item_id or None, + content=output.rstrip("\n") or "(no output)", + is_error=is_error, + )) + elif item_type == "fileChange": + failed = str(item.get("status") or "").lower() == "failed" + changes = self._changes(item) or self._changes(started or {}) + for n, change in enumerate(changes): + diff = str(change.get("diff") or "") + out.append(ev.ToolResult( + tool_use_id=self._change_id(item_id, n), + content=diff or f"({change.get('kind') or 'change'} applied)", + is_error=failed, + )) + elif item_type == "mcpToolCall": + status = str(item.get("status") or "").lower() + result = item.get("result") + if result is None: + result = item.get("output") or item.get("error") or "" + out.append(ev.ToolResult( + tool_use_id=item_id or None, + content=result if isinstance(result, str) else json.dumps( + result, default=str, + ), + is_error=status == "failed", + )) + elif item_type == "webSearch": + out.append(ev.ToolResult( + tool_use_id=item_id or None, + content=json.dumps( + item.get("results", item.get("result", "")), default=str, + )[:4000], + is_error=False, + )) + # agentMessage completion: text was already streamed via deltas. + return out + + def _map_turn_completed(self, params: dict) -> ev.TurnCompleted: + turn = params.get("turn") or {} + status = str(turn.get("status") or "completed").lower() + if status not in ("completed", "interrupted", "failed"): + logger.warning("codex: unexpected turn status %r", status) + status = "completed" + + error = None + if status == "failed": + terr = turn.get("error") + if isinstance(terr, dict): + error = str(terr.get("message") or terr) + else: + error = str(terr) if terr else (self._last_error or "turn failed") + + usage = self._normalize_usage(self._turn_usage) + cost = compute_cost(self.model, usage, self._backend.codex.pricing) + return ev.TurnCompleted( + native_session_id=self._thread_id, + model=self.model, + usage=usage, + total_cost_usd=cost, # per-turn (cost_is_cumulative=False) + duration_ms=turn.get("durationMs"), + duration_api_ms=None, + num_turns=1, + context_window=self._context_window, + status=status, # type: ignore[arg-type] + error=error, + ) + + @staticmethod + def _normalize_usage(usage: dict | None) -> ev.NormalizedUsage | None: + """``thread/tokenUsage/updated`` → NormalizedUsage. + + OpenAI's ``inputTokens`` INCLUDES the cached subset; nerve's + Anthropic-style accounting keeps them disjoint (input = full + price only), so the cached count is subtracted out here — the + pricing module and the usage-dict contract both rely on it. + """ + if not usage: + return None + last = usage.get("last") or {} + if not isinstance(last, dict): + return None + input_tokens = int(last.get("inputTokens") or 0) + cached = int(last.get("cachedInputTokens") or 0) + return ev.NormalizedUsage( + input_tokens=max(0, input_tokens - cached), + output_tokens=int(last.get("outputTokens") or 0), + cache_read_tokens=cached, + cache_creation_tokens=0, + raw={"last": last, "total": usage.get("total")}, + ) + + # -- item helpers ----------------------------------------------------- # + + @staticmethod + def _command_str(item: dict) -> str: + command = item.get("command") + if isinstance(command, list): + return " ".join(str(part) for part in command) + return str(command or "") + + @staticmethod + def _changes(item: dict) -> list[dict]: + changes = item.get("changes") + if isinstance(changes, list): + return [c for c in changes if isinstance(c, dict)] + return [] + + @staticmethod + def _change_id(item_id: str, n: int) -> str: + return f"{item_id}:{n}" if item_id else f"change:{n}" + + @staticmethod + def _mcp_tool_name(item: dict) -> str: + server = str(item.get("server") or "mcp") + tool = str(item.get("tool") or item.get("toolName") or "call") + # codex reports plain server/tool; render the canonical MCP id so + # existing stats/UI paths (mcp__server__tool parsing) work. + if tool.startswith("mcp__"): + return tool + return f"mcp__{server}__{tool}" + + @staticmethod + def _mcp_tool_input(item: dict) -> dict: + args = item.get("arguments") or item.get("input") or {} + if isinstance(args, str): + try: + parsed = json.loads(args) + return parsed if isinstance(parsed, dict) else {"arguments": args} + except ValueError: + return {"arguments": args} + return args if isinstance(args, dict) else {} + + # -- approvals (server-initiated requests) ---------------------------- # + + async def _handle_server_request(self, method: str, params: dict) -> dict: + if method in ( + "item/commandExecution/requestApproval", + "execCommandApproval", + ): + return await self._approval( + "command_approval", self._approval_payload(params), + ) + if method in ("item/fileChange/requestApproval", "applyPatchApproval"): + return await self._approval( + "file_approval", self._approval_payload(params), + ) + if method == "item/permissions/requestApproval": + return await self._approval( + "permission_approval", self._approval_payload(params), + ) + if method == "item/tool/requestUserInput": + # v1: nerve's ask_user covers interactive questions; decline + # tool-level input requests explicitly (docs plan §7). + logger.warning( + "codex requested tool user input — declining (unsupported): %s", + str(params)[:200], + ) + return {} + if method == "mcpServer/elicitation/request": + logger.warning("codex MCP elicitation declined (unsupported)") + return {} + + if method.endswith("requestApproval") or method.endswith("Approval"): + logger.warning( + "codex: unknown approval request %s — declining", method, + ) + return {"decision": "decline"} + logger.warning("codex: unknown server request %s — empty reply", method) + return {} + + def _approval_payload(self, params: dict) -> dict: + """Attach the originating item's context (the raw request carries + only ids/reason — the UI wants the command / changed files).""" + payload = dict(params) + item_id = str(params.get("itemId") or "") + item = self._items.get(item_id) + if item: + payload["item"] = item + return payload + + async def _approval(self, kind: str, payload: dict) -> dict: + hub = self._spec.interactive + if hub is None: + return {"decision": "accept"} + outcome = await hub.request_approval(kind, payload) + return {"decision": "accept" if outcome.approved else "decline"} + + # -- lifecycle -------------------------------------------------------- # + + async def interrupt(self) -> None: + if self._thread_id and self._turn_id: + try: + await self._transport.request("turn/interrupt", { + "threadId": self._thread_id, + "turnId": self._turn_id, + }, timeout=10.0) + except (CodexRpcError, TransportDiedError) as e: + logger.debug("codex interrupt failed: %s", e) + + async def disconnect(self) -> None: + await self._transport.close() + + def is_alive(self) -> bool: + return self._transport.is_alive() + + # -- idle stream: not supported (no autonomous turns) ------------------ # + + def try_receive_idle_events(self) -> list[ev.AgentEvent] | None: + return None + + async def receive_idle_events( + self, timeout: float | None, + ) -> list[ev.AgentEvent] | None: + return None + + def buffer_used(self) -> int: + return 0 diff --git a/nerve/agent/backends/codex/pricing.py b/nerve/agent/backends/codex/pricing.py new file mode 100644 index 0000000..da0dade --- /dev/null +++ b/nerve/agent/backends/codex/pricing.py @@ -0,0 +1,63 @@ +"""Codex (OpenAI) turn pricing. + +The app-server reports token usage but never a USD figure, so cost is +computed here from the config-driven price table +(``codex.pricing: {model_substring: {input, cached_input, output}}``, +$/1M tokens). + +Semantics: OpenAI's ``inputTokens`` INCLUDES ``cachedInputTokens`` (the +cached subset bills at the discounted rate). The backend normalizes that +into nerve's Anthropic-style split — ``input_tokens`` = full-price +tokens only, ``cache_read_tokens`` = the cached subset — *before* this +module runs, so: + + cost = input*in + cache_read*cached_in + output*out + +Unknown model → ``None``, never estimated: a wrong cost is worse than a +missing one (the token counts are still recorded). +""" + +from __future__ import annotations + +from nerve.agent.backends.events import NormalizedUsage + + +def match_pricing( + model: str | None, table: dict[str, dict[str, float]], +) -> dict[str, float] | None: + """Longest-substring match of *model* against the price table keys. + + Mirrors the matching style of ``nerve.db.usage.MODEL_PRICING`` so + dated/suffixed aliases resolve to their family entry. + """ + if not model or not table: + return None + m = model.lower() + best_key = "" + for key in table: + k = key.lower() + if k and k in m and len(k) > len(best_key): + best_key = key + return table.get(best_key) if best_key else None + + +def compute_cost( + model: str | None, + usage: NormalizedUsage | None, + table: dict[str, dict[str, float]], +) -> float | None: + """Per-turn USD cost, or ``None`` when the model has no table entry.""" + if usage is None: + return None + prices = match_pricing(model, table) + if prices is None: + return None + per_m = 1_000_000 + in_rate = float(prices.get("input") or 0.0) + cached_rate = float(prices.get("cached_input") or 0.0) + out_rate = float(prices.get("output") or 0.0) + return ( + usage.input_tokens * in_rate + + usage.cache_read_tokens * cached_rate + + usage.output_tokens * out_rate + ) / per_m diff --git a/nerve/agent/backends/events.py b/nerve/agent/backends/events.py new file mode 100644 index 0000000..c4051c4 --- /dev/null +++ b/nerve/agent/backends/events.py @@ -0,0 +1,182 @@ +"""Normalized agent events — the engine↔backend boundary vocabulary. + +Every backend translates its native stream into these types; the engine +consumes ONLY these. No ``claude_agent_sdk`` or Codex protocol types may +cross this boundary (see docs/plans/codex-backend.md §1/§4). + +Design notes: + +* One native message may translate into several events (a multi-block + Claude ``AssistantMessage`` yields one event per block). +* ``TurnCompleted`` is the terminal event of every turn — including + interrupted and failed turns — so the engine's turn loop has exactly + one exit shape. +* ``NormalizedUsage.to_anthropic_shape()`` defines the usage-dict + contract for everything downstream of the engine (DB usage rows, the + ``done`` broadcast the web UI reads, the cache-TTL split). The Claude + backend passes its native dict through untouched (it already IS this + shape); Codex maps its camelCase breakdown into it. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Literal + + +@dataclass +class NormalizedUsage: + """Backend-neutral token usage for a single turn. + + ``raw`` retains the backend-native payload for diagnostics (persisted + inside the anthropic-shaped dict under ``_raw`` when it differs). + """ + + input_tokens: int = 0 + output_tokens: int = 0 + cache_read_tokens: int = 0 + cache_creation_tokens: int = 0 + raw: dict[str, Any] = field(default_factory=dict) + + @classmethod + def from_anthropic(cls, usage: dict[str, Any]) -> "NormalizedUsage": + """Build from an Anthropic-shaped usage dict (Claude backend).""" + return cls( + input_tokens=int(usage.get("input_tokens") or 0), + output_tokens=int(usage.get("output_tokens") or 0), + cache_read_tokens=int(usage.get("cache_read_input_tokens") or 0), + cache_creation_tokens=int(usage.get("cache_creation_input_tokens") or 0), + raw=usage, + ) + + def to_anthropic_shape(self) -> dict[str, Any]: + """The engine-facing usage dict. + + For the Claude backend ``raw`` is already Anthropic-shaped and is + returned untouched — preserving nested fields the cache-TTL split + reads (``cache_creation.ephemeral_*``) byte-for-byte. For other + backends the canonical keys are synthesized and the native payload + is kept under ``_raw``. + """ + if "input_tokens" in self.raw: + return self.raw + shaped: dict[str, Any] = { + "input_tokens": self.input_tokens, + "output_tokens": self.output_tokens, + "cache_read_input_tokens": self.cache_read_tokens, + "cache_creation_input_tokens": self.cache_creation_tokens, + } + if self.raw: + shaped["_raw"] = self.raw + return shaped + + +@dataclass +class TextDelta: + """Streamed assistant text.""" + + text: str + parent_tool_use_id: str | None = None + + +@dataclass +class ThinkingDelta: + """Streamed reasoning/thinking text.""" + + text: str + parent_tool_use_id: str | None = None + + +@dataclass +class ToolUse: + """The agent invoked a tool.""" + + tool_use_id: str | None + name: str + input: dict[str, Any] + parent_tool_use_id: str | None = None + + +@dataclass +class ToolResult: + """A tool finished; ``content`` mirrors the SDK's content shape + (string, list of content blocks, or None).""" + + tool_use_id: str | None + content: Any + is_error: bool = False + parent_tool_use_id: str | None = None + + +@dataclass +class SubagentStarted: + """A sub-agent (Claude ``Task``/``Agent`` tool) began. Claude-only.""" + + tool_use_id: str + subagent_type: str + description: str + model: str | None = None + + +@dataclass +class ModelObserved: + """The serving model was observed/changed mid-stream. + + Claude: ``AssistantMessage.model`` on main-agent messages. + Codex: resolved thread model at turn start + ``model/rerouted``. + Feeds ``st.last_model`` / serving-model change detection. + """ + + model: str + + +@dataclass +class SystemEvent: + """Backend system/meta message passthrough. + + Claude: ``SystemMessage`` subtypes (init, task_* lifecycle chips, + workflow progress). Codex: plan deltas (``codex_plan``), retryable + errors (``codex_error``), rate-limit updates. The engine routes by + ``subtype``; unknown subtypes are informational only. + """ + + subtype: str + data: dict[str, Any] = field(default_factory=dict) + + +TurnStatus = Literal["completed", "interrupted", "failed"] + + +@dataclass +class TurnCompleted: + """Terminal event of a turn — always emitted exactly once per turn. + + ``total_cost_usd`` semantics depend on the backend's + ``cost_is_cumulative`` capability: Claude reports a process-cumulative + figure (the engine diffs it); Codex reports THIS turn's cost, + pre-computed from the pricing table (``None`` when the model has no + table entry — never estimated). + """ + + native_session_id: str | None = None + model: str | None = None + usage: NormalizedUsage | None = None + total_cost_usd: float | None = None + duration_ms: int | None = None + duration_api_ms: int | None = None + num_turns: int | None = None + context_window: int | None = None + status: TurnStatus = "completed" + error: str | None = None + + +AgentEvent = ( + TextDelta + | ThinkingDelta + | ToolUse + | ToolResult + | SubagentStarted + | ModelObserved + | SystemEvent + | TurnCompleted +) diff --git a/nerve/agent/backends/images.py b/nerve/agent/backends/images.py new file mode 100644 index 0000000..c9f1d56 --- /dev/null +++ b/nerve/agent/backends/images.py @@ -0,0 +1,135 @@ +"""Shared image validation for agent backends. + +Moved verbatim from ``nerve/agent/engine.py`` — both backends validate +inbound images before handing them to their runtime: an unprocessable +image in a conversation history can poison every subsequent API call +(the Claude case that motivated this), and no runtime benefits from +garbage bytes. +""" + +from __future__ import annotations + +import os + +# Anthropic API image limit; a sane general ceiling for codex too. +MAX_IMAGE_BYTES = 5 * 1024 * 1024 # 5 MB + +IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp"} + +# Magic byte signatures for supported image formats. +# Each format maps to a list of valid signatures. A signature is a list +# of (magic_bytes, offset) pairs that must ALL match (AND logic). +IMAGE_MAGIC: dict[str, list[list[tuple[bytes, int]]]] = { + ".png": [[(b"\x89PNG\r\n\x1a\n", 0)]], + ".jpg": [[(b"\xff\xd8\xff", 0)]], + ".jpeg": [[(b"\xff\xd8\xff", 0)]], + ".gif": [[(b"GIF87a", 0)], [(b"GIF89a", 0)]], + # WebP is RIFF container: must have RIFF at 0 AND WEBP at 8 + ".webp": [[(b"RIFF", 0), (b"WEBP", 8)]], +} + + +def validate_image_file(file_path: str) -> str | None: + """Validate that a file with an image extension contains actual image data. + + Returns None if valid, or an error string describing the problem. + This prevents the runtime from base64-encoding non-image files (e.g. + HTML redirect pages saved with a .png extension) and poisoning the + conversation context with an unprocessable image block. + """ + from pathlib import Path + + ext = Path(file_path).suffix.lower() + if ext not in IMAGE_EXTENSIONS: + return None # Not an image — nothing to validate + + try: + size = os.path.getsize(file_path) + except OSError: + return None # Let the Read tool handle missing files + + if size == 0: + return f"Image file is empty (0 bytes): {file_path}" + + if size > MAX_IMAGE_BYTES: + size_mb = size / (1024 * 1024) + return ( + f"Image file too large ({size_mb:.1f} MB > 5 MB API limit): {file_path}. " + f"The Anthropic API rejects images larger than 5 MB." + ) + + # Check magic bytes + magic_specs = IMAGE_MAGIC.get(ext, []) + if not magic_specs: + return None # No magic spec — let it through + + try: + with open(file_path, "rb") as f: + header = f.read(16) + except OSError: + return None # Let the Read tool handle I/O errors + + # Each signature is a list of (bytes, offset) pairs — ALL must match. + # Multiple signatures per format are OR'd (e.g. GIF87a vs GIF89a). + for signature in magic_specs: + if all( + header[off: off + len(magic)] == magic + for magic, off in signature + ): + return None # Valid magic — good to go + + # None of the magic signatures matched + # Check if it's actually HTML (common when auth fails on image URLs) + is_html = header.lstrip()[:5].lower() in (b" str | None: + """Validate base64-encoded image data before sending to the API. + + Returns None if valid, or an error string describing the problem. + Used for images entering through Nerve's own pipeline (Telegram, etc). + """ + import base64 + + try: + raw = base64.b64decode(data_b64[:64]) # Only need first bytes + except Exception: + return f"Invalid base64 encoding for {media_type} image" + + if len(raw) < 4: + return f"Image data too small ({len(raw)} bytes) for {media_type}" + + # Map media_type to extension for magic check + type_to_ext = { + "image/png": ".png", + "image/jpeg": ".jpg", + "image/gif": ".gif", + "image/webp": ".webp", + } + ext = type_to_ext.get(media_type) + if not ext: + return None # Unknown type — let the API decide + + magic_specs = IMAGE_MAGIC.get(ext, []) + for signature in magic_specs: + if all( + raw[off: off + len(magic)] == magic + for magic, off in signature + ): + return None # Valid + + return ( + f"Image data does not match declared type {media_type}. " + f"The file header bytes do not contain a valid {ext.upper().strip('.')} signature." + ) diff --git a/nerve/agent/engine.py b/nerve/agent/engine.py index 6dbe9d2..f5c4620 100644 --- a/nerve/agent/engine.py +++ b/nerve/agent/engine.py @@ -1,8 +1,12 @@ -"""Agent engine — Claude Agent SDK wrapper. - -Orchestrates SDK clients and delegates all session state to SessionManager. -The SDK handles context management and compaction internally. -Sessions are resumable across server restarts via SDK's --resume flag. +"""Agent engine — backend-agnostic agent orchestration. + +Orchestrates per-session agent clients (Claude Agent SDK, OpenAI Codex +app-server — see :mod:`nerve.agent.backends`) and delegates all session +state to SessionManager. The engine consumes only normalized +:mod:`nerve.agent.backends.events`; every runtime-specific type stays +inside its backend module. Sessions are resumable across server restarts +via each backend's native resume mechanism, routed by the sticky +``sessions.backend`` column (docs/plans/codex-backend.md §3). """ from __future__ import annotations @@ -13,28 +17,28 @@ import logging import os import re -import time from dataclasses import dataclass, field from datetime import datetime, timedelta, timezone from typing import Any -import anyio - -from claude_agent_sdk import ( - AssistantMessage, - ClaudeAgentOptions, - ClaudeSDKClient, - ResultMessage, - SystemMessage, - UserMessage, - TextBlock, - ToolUseBlock, - ToolResultBlock, +from nerve.agent.backends import ( + AgentBackend, + AgentClient, + BackendDeps, + ModelObserved, + SessionSpec, + SubagentStarted, + SystemEvent, + TextDelta, + ThinkingDelta, + ToolResult, + ToolUse, + TransportDiedError, + TurnCompleted, + TurnInput, + build_backends, ) -from claude_agent_sdk._errors import CLIConnectionError -from claude_agent_sdk.types import HookMatcher, HookJSONOutput, HookContext - -from nerve.agent.cache_policy import cache_ttl_env, resolve_cache_ttl +from nerve.agent.cache_policy import resolve_cache_ttl from nerve.agent.interactive import ( InteractiveToolHandler, register_handler, @@ -52,7 +56,6 @@ ToolContext, ToolRegistry, build_default_registry, - build_session_mcp_server, ) # Legacy back-compat: ``init_tools`` populates ``nerve.agent.tools``'s # module globals so test fixtures that patch them and the shared @@ -67,153 +70,9 @@ logger = logging.getLogger(__name__) -try: - from claude_agent_sdk import ThinkingBlock -except ImportError: - ThinkingBlock = None - _SURROGATE_RE = re.compile(r"[\ud800-\udfff]") -# Anthropic API image limits -_MAX_IMAGE_BYTES = 5 * 1024 * 1024 # 5 MB -_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp"} - -# Linux execve() limits a single argv element to MAX_ARG_STRLEN = PAGE_SIZE * 32 -# = 131,072 bytes on common configurations. The Claude Agent SDK passes the -# system prompt inline as `--system-prompt `, which makes the string a -# single argv element. When SOUL.md + TASK.md + AGENTS.md + TOOLS.md + -# MEMORY.md + recalled memU summaries cross that boundary, execve() returns -# E2BIG ("Argument list too long") and Claude Code fails to start. -# -# We sidestep the limit by writing the prompt to a file and passing -# `SystemPromptFile = {"type": "file", "path": ...}` (which the SDK converts -# to `--system-prompt-file ` — the path string is short). -# -# Threshold below which we keep passing inline (preserves prompt-cache hit -# behavior for small, stable prompts). Set conservatively well under the -# kernel limit to leave room for env/argv overhead. -_SYSTEM_PROMPT_INLINE_MAX = 100_000 # bytes - -# Magic byte signatures for supported image formats. -# Each format maps to a list of valid signatures. A signature is a list -# of (magic_bytes, offset) pairs that must ALL match (AND logic). -_IMAGE_MAGIC: dict[str, list[list[tuple[bytes, int]]]] = { - ".png": [[(b"\x89PNG\r\n\x1a\n", 0)]], - ".jpg": [[(b"\xff\xd8\xff", 0)]], - ".jpeg": [[(b"\xff\xd8\xff", 0)]], - ".gif": [[(b"GIF87a", 0)], [(b"GIF89a", 0)]], - # WebP is RIFF container: must have RIFF at 0 AND WEBP at 8 - ".webp": [[(b"RIFF", 0), (b"WEBP", 8)]], -} - - -def _validate_image_file(file_path: str) -> str | None: - """Validate that a file with an image extension contains actual image data. - - Returns None if valid, or an error string describing the problem. - This prevents the CLI from base64-encoding non-image files (e.g. HTML - redirect pages saved with a .png extension) and poisoning the - conversation context with an unprocessable image block. - """ - from pathlib import Path - - ext = Path(file_path).suffix.lower() - if ext not in _IMAGE_EXTENSIONS: - return None # Not an image — nothing to validate - - try: - size = os.path.getsize(file_path) - except OSError: - return None # Let the Read tool handle missing files - - if size == 0: - return f"Image file is empty (0 bytes): {file_path}" - - if size > _MAX_IMAGE_BYTES: - size_mb = size / (1024 * 1024) - return ( - f"Image file too large ({size_mb:.1f} MB > 5 MB API limit): {file_path}. " - f"The Anthropic API rejects images larger than 5 MB." - ) - - # Check magic bytes - magic_specs = _IMAGE_MAGIC.get(ext, []) - if not magic_specs: - return None # No magic spec — let it through - - try: - with open(file_path, "rb") as f: - header = f.read(16) - except OSError: - return None # Let the Read tool handle I/O errors - - # Each signature is a list of (bytes, offset) pairs — ALL must match. - # Multiple signatures per format are OR'd (e.g. GIF87a vs GIF89a). - for signature in magic_specs: - if all( - header[off: off + len(magic)] == magic - for magic, off in signature - ): - return None # Valid magic — good to go - - # None of the magic signatures matched - # Check if it's actually HTML (common when auth fails on image URLs) - is_html = header.lstrip()[:5].lower() in (b" str | None: - """Validate base64-encoded image data before sending to the API. - - Returns None if valid, or an error string describing the problem. - Used for images entering through Nerve's own pipeline (Telegram, etc). - """ - import base64 - - try: - raw = base64.b64decode(data_b64[:64]) # Only need first bytes - except Exception: - return f"Invalid base64 encoding for {media_type} image" - - if len(raw) < 4: - return f"Image data too small ({len(raw)} bytes) for {media_type}" - - # Map media_type to extension for magic check - type_to_ext = { - "image/png": ".png", - "image/jpeg": ".jpg", - "image/gif": ".gif", - "image/webp": ".webp", - } - ext = type_to_ext.get(media_type) - if not ext: - return None # Unknown type — let the API decide - - magic_specs = _IMAGE_MAGIC.get(ext, []) - for signature in magic_specs: - if all( - raw[off: off + len(magic)] == magic - for magic, off in signature - ): - return None # Valid - - return ( - f"Image data does not match declared type {media_type}. " - f"The file header bytes do not contain a valid {ext.upper().strip('.')} signature." - ) - - def _sanitize_surrogates(s: str) -> str: """Remove orphaned UTF-16 surrogates that break JSON serialization. @@ -390,6 +249,24 @@ def __init__(self, config: NerveConfig, db: Database): # up the reference from here. self.notification_service: Any = None + # Agent backends (claude / codex). Constructed once; resolved per + # session by the STICKY rule (stored sessions.backend first, then + # config) — see _backend_for. ``_session_backends`` mirrors the + # live client's backend for hot paths (finalize, idle watcher). + self._backends: dict[str, AgentBackend] = build_backends( + BackendDeps( + config=self.config, + db=self.db, + registry=self.registry, + tool_ctx_factory=self._build_tool_context, + external_mcp_servers=lambda: self._mcp_servers_cache, + claude_plugins=lambda: self._claude_code_plugins, + gateway_port=self._gateway_port, + mint_session_token=self._mint_mcp_session_token, + ) + ) + self._session_backends: dict[str, str] = {} + def set_notification_service(self, service: Any) -> None: """Install the notification service used by per-session ``ToolContext``. @@ -402,6 +279,106 @@ def get_active_channel(self, session_id: str) -> str | None: """Return the channel name currently driving ``session_id`` (or None).""" return self._active_channel.get(session_id) + # ------------------------------------------------------------------ # + # Backend resolution (sticky per session) # + # ------------------------------------------------------------------ # + + def _build_tool_context(self, session_id: str) -> ToolContext: + """Fresh per-session ToolContext for backend MCP servers.""" + return ToolContext( + session_id=session_id, + workspace=self.config.workspace, + db=self.db, + memory_bridge=self._memory_bridge, + xmemory_bridge=self._xmemory_bridge, + config=self.config, + skill_manager=self._skill_manager, + engine=self, + notification_service=self.notification_service, + ) + + def _gateway_port(self) -> int | None: + """Gateway port for the loopback MCP bridge (codex tool access).""" + if not self.config.mcp_endpoint.enabled: + return None + return int(self.config.gateway.port) + + def _mint_mcp_session_token(self, session_id: str) -> str: + """Session-bound bearer token for a backend-managed agent process.""" + from nerve.gateway.auth import create_mcp_session_token + + if not self.config.auth.jwt_secret: + return "" # dev mode — endpoint accepts unauthenticated calls + return create_mcp_session_token(self.config.auth.jwt_secret, session_id) + + def _backend_for(self, session: dict | None, source: str) -> AgentBackend: + """Resolve the backend for a session — STICKY on the stored column. + + 1. ``sessions.backend`` set → that backend, always. Wakeup / + internal / cron-fired turns on an existing session can never + cross backends, no matter what the config says now (a stored + native session id is meaningless on another runtime). + 2. New sessions: metadata ``backend_override`` (per-session A/B + hook) → config (``agent.cron_backend`` for cron/hook sources, + ``agent.backend`` otherwise). + """ + stored = (session or {}).get("backend") + name = str(stored) if stored else "" + if not name: + try: + meta = json.loads((session or {}).get("metadata") or "{}") + except (TypeError, ValueError): + meta = {} + override = meta.get("backend_override") + if override: + name = str(override).strip().lower() + if not name: + if source in self._CRON_EFFORT_SOURCES: + name = self.config.agent.resolved_cron_backend + else: + name = self.config.agent.backend + backend = self._backends.get(name) + if backend is None: + raise RuntimeError( + f"Session requires backend {name!r} which is not available " + f"(known: {sorted(self._backends)}). Restore the backend " + "config or start a new session." + ) + return backend + + def _backend_for_live_session(self, session_id: str) -> AgentBackend: + """Backend of the session's live client (defaults to claude).""" + name = self._session_backends.get(session_id, "claude") + return self._backends.get(name) or self._backends["claude"] + + def _collect_skill_summaries(self) -> list[dict] | None: + """Skill summaries for the system prompt (moved from _build_options). + + Sorted for deterministic system-prompt bytes — scan order varies + across restarts, and a reordered skill list would silently + invalidate every session's prompt cache after a restart. + """ + if not self._skill_manager: + return None + try: + loop = asyncio.get_event_loop() + if loop.is_running(): + summaries = [] + for sid, meta in sorted(self._skill_manager._cache.items()): + if meta.enabled and meta.model_invocable: + summaries.append({ + "id": meta.id, + "name": meta.name, + "description": meta.description, + }) + return summaries + return loop.run_until_complete( + self._skill_manager.get_enabled_summaries() + ) + except Exception as e: + logger.warning("Failed to get skill summaries: %s", e) + return None + async def initialize(self) -> None: """Initialize the agent engine — set up tools and main session.""" from nerve.memory.memu_bridge import MemUBridge @@ -590,74 +567,17 @@ async def _run_worker_onboarding(self) -> None: @staticmethod async def _safe_disconnect(client: Any, timeout: float = 5.0) -> None: - """Disconnect an SDK client without risking an event-loop spin. - - The SDK's Query.close() cancels its anyio task group before closing - the transport. If any task inside that group cannot exit promptly - (e.g. _read_messages stuck on process.wait(), _handle_control_request - writing to a dead pipe, or _message_send buffer full), the anyio - _deliver_cancellation callback spins at 100% CPU forever. - - Strategy: - 1. Kill the subprocess immediately (SIGKILL) so every I/O wait - inside the task group unblocks. - 2. Attempt a clean disconnect() with a short timeout. - 3. If that times out, forcibly disarm the anyio task group so - _deliver_cancellation has nothing left to spin on. - """ - # --- 1. Kill subprocess immediately --- - transport = getattr( - getattr(client, "_query", None), "transport", None, - ) - proc = getattr(transport, "_process", None) - if proc is not None and proc.returncode is None: - try: - proc.kill() - except Exception: - pass - - # --- 2. Try a clean disconnect with a timeout --- - try: - await asyncio.wait_for(client.disconnect(), timeout=timeout) - return - except asyncio.TimeoutError: - logger.warning( - "SDK client disconnect timed out after %.1fs — " - "force-clearing task group to stop _deliver_cancellation spin", - timeout, - ) - except Exception: - pass - - # --- 3. Forcibly disarm the stuck task group --- - query = getattr(client, "_query", None) - if query is None: - return - tg = getattr(query, "_tg", None) - if tg is None: - return - - # Cancel the pending _deliver_cancellation handle so it stops - # rescheduling itself via call_soon(). - cs = getattr(tg, "cancel_scope", None) - handle = getattr(cs, "_cancel_handle", None) - if handle is not None: - handle.cancel() - cs._cancel_handle = None + """Tear a client down without letting teardown errors propagate. - # Clear task sets so a stray _deliver_cancellation finds nothing. - if cs is not None: - cs._tasks.clear() - tg._tasks.clear() - - # Close the transport directly (kills process, closes pipes). + The hardened teardown logic (subprocess kill, anyio task-group + disarm for the Claude SDK) lives in each backend's + ``AgentClient.disconnect`` — this wrapper only guarantees the + call can't take forever or raise into engine control flow. + """ try: - await asyncio.wait_for(query.transport.close(), timeout=2.0) - except Exception: - pass - - client._query = None - client._transport = None + await asyncio.wait_for(client.disconnect(), timeout=timeout + 5.0) + except Exception as e: + logger.warning("Client disconnect failed: %s", e) async def shutdown(self) -> None: """Disconnect all persistent clients and mark sessions as idle. @@ -951,440 +871,6 @@ async def run_memorization_sweep(self) -> dict: logger.info("Memorization sweep: %s", stats) return stats - # ------------------------------------------------------------------ # - # SDK options # - # ------------------------------------------------------------------ # - - def _build_options( - self, - session_id: str, - source: str = "web", - model: str | None = None, - recalled_memories: list[str] | None = None, - resume: str | None = None, - fork_session: bool = False, - can_use_tool=None, - cache_ttl: str = "5m", - ) -> ClaudeAgentOptions: - """Build SDK client options for a session.""" - # Get skill summaries for system prompt injection - skill_summaries = None - if self._skill_manager: - try: - import asyncio - # get_enabled_summaries is a coroutine but _build_options is sync - # Use the running loop if available - loop = asyncio.get_event_loop() - if loop.is_running(): - # We're in an async context — schedule and await later - # For now, use cached data from the manager - # Sorted for deterministic system-prompt bytes — scan - # order varies across restarts, and a reordered skill - # list would silently invalidate every session's prompt - # cache after a restart. - skill_summaries = [] - for sid, meta in sorted(self._skill_manager._cache.items()): - if meta.enabled and meta.model_invocable: - skill_summaries.append({ - "id": meta.id, - "name": meta.name, - "description": meta.description, - }) - else: - skill_summaries = loop.run_until_complete( - self._skill_manager.get_enabled_summaries() - ) - except Exception as e: - logger.warning("Failed to get skill summaries: %s", e) - - system_prompt_str = build_system_prompt( - workspace=self.config.workspace, - session_id=session_id, - source=source, - timezone_name=self.config.timezone, - recalled_memories=recalled_memories, - skill_summaries=skill_summaries, - ) - - # Pass the system prompt as a file when it's large enough to risk - # hitting Linux's MAX_ARG_STRLEN argv-element limit. See the comment - # near _SYSTEM_PROMPT_INLINE_MAX at module scope. The SDK accepts - # `SystemPromptFile` ({"type": "file", "path": ...}) and converts it - # to `--system-prompt-file ` on the CLI. - system_prompt: str | dict[str, Any] - if len(system_prompt_str) > _SYSTEM_PROMPT_INLINE_MAX: - sp_path = self._write_system_prompt_file(session_id, system_prompt_str) - system_prompt = {"type": "file", "path": sp_path} - logger.info( - "Session %s: system prompt %d bytes (> %d), passing via file %s", - session_id[:8], - len(system_prompt_str), - _SYSTEM_PROMPT_INLINE_MAX, - sp_path, - ) - else: - system_prompt = system_prompt_str - - # Local Ollama models are reached through the proxy and speak the - # OpenAI-translated API — Anthropic-only knobs (extended thinking, - # effort, the context-1m beta) don't apply and may break translation, - # so suppress them for non-Claude models. - selected_model = model or self.config.agent.model - is_ollama_model = ( - self.config.ollama.enabled and "claude" not in selected_model.lower() - ) - - thinking_config = ( - None if is_ollama_model - else self._parse_thinking_config(self.config.agent.thinking, selected_model) - ) - # Cron- and hook-sourced turns are sensing / triage work that fires - # far more often than interactive sessions; run them at the lower - # cron_effort to cut token spend. Interactive sources (web, telegram, - # wakeup) keep the full agent.effort. - base_effort = self._base_effort_for_source( - source, self.config.agent.effort, self.config.agent.cron_effort - ) - effort = ( - None if is_ollama_model - else self._effective_effort(base_effort, selected_model) - ) - # Some subscriptions reject the context-1m beta for specific models - # (e.g. claude-sonnet-4-6) — skip the beta header for those. - betas = ( - ["context-1m-2025-08-07"] - if not is_ollama_model and self.config.agent.context_1m_enabled_for(model) - else [] - ) - - # Build PreToolUse (file snapshots, image validation) + - # PostToolUse (ScheduleWakeup capture) hooks. - hooks = self._build_hooks(session_id) - - def _cli_stderr(line: str) -> None: - stripped = line.rstrip() - if not stripped: - return - # Filter debug-to-stderr output by severity - if "[ERROR]" in stripped or "[FATAL]" in stripped: - logger.error("CLI stderr [%s]: %s", session_id[:8], stripped) - elif "[WARN]" in stripped: - logger.warning("CLI stderr [%s]: %s", session_id[:8], stripped) - elif "[DEBUG]" in stripped or "[INFO]" in stripped: - logger.debug("CLI stderr [%s]: %s", session_id[:8], stripped) - else: - # Non-debug lines (e.g. raw warnings from the CLI) - logger.warning("CLI stderr [%s]: %s", session_id[:8], stripped) - - extra_args: dict[str, str | None] = {"debug-to-stderr": None} - # Opus 4.7 defaults thinking.display to "omitted", returning empty - # thinking blocks with only a signature (for multi-turn continuity). - # Force "summarized" so the UI actually has thinking text to render. - # The CLI ignores this flag when thinking is disabled. - # NOTE: --thinking-display hangs on Bedrock (multi-turn after ToolSearch - # never returns). Disabled for Bedrock until the provider bug is fixed. - if ( - thinking_config - and thinking_config.get("type") != "disabled" - and not self.config.provider.is_bedrock - ): - extra_args["thinking-display"] = "summarized" - - return ClaudeAgentOptions( - model=model or self.config.agent.model, - system_prompt=system_prompt, - max_turns=self.config.agent.max_turns, - # No permission_mode — can_use_tool callback handles all permissions. - # Interactive tools pause for user input; everything else auto-approves. - can_use_tool=can_use_tool, - thinking=thinking_config, - effort=effort, - betas=betas, - resume=resume, - fork_session=fork_session, - hooks=hooks, - stderr=_cli_stderr, - extra_args=extra_args, - # No allowed_tools — can_use_tool callback handles permissions. - # External MCP server tools are discovered at connection time, - # so we can't enumerate them upfront. - # - # Remove the CLI's cron tools — Nerve has its own cron system, - # so exposing CronCreate/CronList/CronDelete is redundant and - # confusing. ``ScheduleWakeup`` stays available and is handled by - # Nerve's wakeup harness (capture hook + cron-service sweep); the - # CLI's own autonomous firing is suppressed via the - # CLAUDE_CODE_DISABLE_CRON env var set in ``_build_env``. - disallowed_tools=["CronCreate", "CronList", "CronDelete"], - env=self._build_env(cache_ttl=cache_ttl), - cwd=str(self.config.workspace), - mcp_servers=self._build_mcp_servers(session_id), - # Claude Code plugins — loaded via --plugin-dir so the CLI - # handles OAuth, credentials, and plugin lifecycle natively. - plugins=self._claude_code_plugins, - ) - - - def _system_prompt_dir(self) -> "os.PathLike[str]": - """Directory where oversized system prompts are spilled to disk. - - Lives under the workspace's `.nerve/cache/system_prompts/` so it's - per-workspace and easy to inspect / clean. - """ - from pathlib import Path - d = Path(self.config.workspace) / ".nerve" / "cache" / "system_prompts" - d.mkdir(parents=True, exist_ok=True) - return d - - def _write_system_prompt_file(self, session_id: str, content: str) -> str: - """Write the system prompt to disk and return its absolute path. - - Uses a deterministic filename so a session that reconnects (resume) - gets the same prompt without re-writing. Best-effort cleanup of stale - files happens lazily on each write — anything older than 7 days is - removed. - """ - import time - from pathlib import Path - - dir_path = Path(self._system_prompt_dir()) - - # Lazy GC: drop files older than 7 days - cutoff = time.time() - 7 * 24 * 3600 - try: - for old in dir_path.iterdir(): - try: - if old.is_file() and old.stat().st_mtime < cutoff: - old.unlink() - except OSError: - pass - except OSError: - pass - - safe_id = re.sub(r"[^A-Za-z0-9_.-]", "_", session_id)[:120] - path = dir_path / f"{safe_id}.md" - path.write_text(content, encoding="utf-8") - return str(path) - - def _build_env(self, cache_ttl: str = "5m") -> dict[str, str]: - """Build environment variables for the SDK subprocess.""" - env: dict[str, str] = {} - # Prompt-cache TTL: the CLI natively supports the 1-hour TTL via - # this env var (it adds the extended-cache-ttl beta header and the - # cache_control ttl itself). Resolved per client build by - # nerve.agent.cache_policy — see that module for the policy. - env.update(cache_ttl_env(cache_ttl, self.config.provider.is_bedrock)) - # Disable the CLI's built-in cron/wakeup scheduler. It fires - # autonomously inside the subprocess, but Nerve only reads the SDK - # stream during an active run() — so a fired turn lands in an unread - # buffer and then desyncs the next real turn. Nerve owns wakeup - # timing instead: a PostToolUse hook records each ScheduleWakeup and - # the cron service fires it via run(..., source="wakeup"). The tool - # itself stays available (this flag only gates the firing hook). - env["CLAUDE_CODE_DISABLE_CRON"] = "1" - if self.config.provider.is_bedrock: - env["CLAUDE_CODE_USE_BEDROCK"] = "1" - if self.config.provider.aws_region: - env["AWS_REGION"] = self.config.provider.aws_region - if self.config.provider.aws_profile: - env["AWS_PROFILE"] = self.config.provider.aws_profile - if self.config.provider.aws_access_key_id: - env["AWS_ACCESS_KEY_ID"] = self.config.provider.aws_access_key_id - env["AWS_SECRET_ACCESS_KEY"] = self.config.provider.aws_secret_access_key - else: - api_key = self.config.effective_api_key - if api_key: - env["ANTHROPIC_API_KEY"] = api_key - if self.config.proxy.enabled: - env["ANTHROPIC_BASE_URL"] = ( - f"http://{self.config.proxy.host}:{self.config.proxy.port}" - ) - return env - - def _build_mcp_servers(self, session_id: str) -> dict[str, Any]: - """Build the mcp_servers dict: built-in nerve + external servers from config. - - Claude Code plugin MCPs are handled separately via the SDK ``plugins`` - field which lets the CLI manage OAuth and plugin lifecycle natively. - """ - # Construct a fresh ``ToolContext`` per session so every tool - # handler sees the correct session_id and the live collaborator - # references. The notification_service may still be ``None`` - # here if a session starts before gateway startup wires it; the - # tools themselves degrade gracefully in that case. - tool_ctx = ToolContext( - session_id=session_id, - workspace=self.config.workspace, - db=self.db, - memory_bridge=self._memory_bridge, - xmemory_bridge=self._xmemory_bridge, - config=self.config, - skill_manager=self._skill_manager, - engine=self, - notification_service=self.notification_service, - ) - include_hoa = bool(self.config.houseofagents.enabled) - servers: dict[str, Any] = { - "nerve": build_session_mcp_server( - self.registry, tool_ctx, include_hoa=include_hoa, - ), - } - for srv in self._mcp_servers_cache: - if srv.enabled and srv.name != "nerve": - try: - servers[srv.name] = srv.to_sdk_config() - except ValueError as e: - logger.warning("Skipping MCP server %r: %s", srv.name, e) - if len(servers) > 1: - logger.debug( - "Session %s: %d MCP servers (%s)", - session_id[:8], len(servers), - ", ".join(servers.keys()), - ) - return servers - - def _build_hooks(self, session_id: str) -> dict: - """Build SDK hooks for this session. - - PreToolUse: file snapshots (Edit/Write/NotebookEdit) and image - validation (Read). PostToolUse: ScheduleWakeup capture, which - records the requested wakeup so the cron-service sweep can fire it - through ``engine.run(..., source="wakeup")`` (the CLI's own - autonomous firing is suppressed — see ``_build_env``). - """ - from nerve.agent.interactive import INTERACTIVE_TOOLS, _read_file_safe - - captured_files: set[str] = set() - - async def _snapshot_hook(hook_input, tool_use_id, context): - """PreToolUse hook: capture file content before Edit/Write/NotebookEdit.""" - tool_input = hook_input.get("tool_input", {}) - file_path = tool_input.get("file_path") or tool_input.get("notebook_path") - - if file_path and file_path not in captured_files: - captured_files.add(file_path) - content = _read_file_safe(file_path) - try: - await self._save_file_snapshot(session_id, file_path, content) - logger.info("Captured file snapshot for %s", file_path) - except Exception as e: - logger.warning("Failed to save file snapshot for %s: %s", file_path, e) - - # Allow the tool to proceed - return {"hookSpecificOutput": {"hookEventName": "PreToolUse"}} - - async def _validate_image_hook(hook_input, tool_use_id, context): - """PreToolUse hook: validate image files before Read to prevent - poisoning the conversation with unprocessable image data. - - The CLI's Read tool detects images by file extension and base64- - encodes them into image content blocks. If the file isn't a valid - image (e.g. an HTML redirect saved as .png), the API rejects it - with 400 "Could not process image". Worse, the bad block persists - in the CLI's conversation history, causing *every* subsequent turn - to fail — an unrecoverable poison loop. - - This hook checks magic bytes and size *before* Read executes, - blocking invalid files with a clear error message so the agent - can adjust. - """ - tool_input = hook_input.get("tool_input", {}) - file_path = tool_input.get("file_path", "") - - error = _validate_image_file(file_path) - if error: - logger.warning( - "Blocked Read of invalid image for session %s: %s", - session_id[:8], error, - ) - return { - "hookSpecificOutput": { - "hookEventName": "PreToolUse", - "permissionDecision": "deny", - "permissionDecisionReason": error, - }, - } - - return {"hookSpecificOutput": {"hookEventName": "PreToolUse"}} - - async def _capture_wakeup_hook(hook_input, tool_use_id, context): - """PostToolUse hook: record a ScheduleWakeup so Nerve can fire it. - - The CLI's own scheduler is disabled (CLAUDE_CODE_DISABLE_CRON), - so the tool just records the request and returns. We persist it - here and the cron-service sweep re-injects the prompt at the - scheduled time via ``engine.run(..., source="wakeup")``. - """ - try: - await self._record_wakeup( - self.db, session_id, hook_input.get("tool_input", {}) or {}, - ) - except Exception as e: - logger.warning( - "Failed to record wakeup for session %s: %s", session_id, e, - ) - return {"hookSpecificOutput": {"hookEventName": "PostToolUse"}} - - async def _grant_permission_hook(hook_input, tool_use_id, context): - """PreToolUse hook: pre-approve non-interactive tools. - - Background sub-agents (the Agent tool with run_in_background) run - detached and non-blocking, so the CLI never surfaces an approval - prompt for their nested tool calls — the ``can_use_tool`` callback - is never invoked for them and the CLI denies their Write/Edit/Bash - by default. A PreToolUse hook, however, DOES fire for those nested - calls (it is a programmatic callback, not a user-facing prompt), so - returning ``permissionDecision: "allow"`` here grants the same - auto-approval foreground agents already get via ``can_use_tool``. - - Interactive tools and Read are left untouched: interactive tools - defer to ``can_use_tool`` (pause / inject answers / deny), and Read - defers to the image validator above plus the CLI's read-only - auto-allow. This keeps the web pause-for-input flow intact while - giving background sub-agents permission parity with the foreground. - """ - tool_name = hook_input.get("tool_name", "") - if tool_name in INTERACTIVE_TOOLS or tool_name == "Read": - return {"hookSpecificOutput": {"hookEventName": "PreToolUse"}} - return { - "hookSpecificOutput": { - "hookEventName": "PreToolUse", - "permissionDecision": "allow", - "permissionDecisionReason": ( - "nerve: auto-approved (background-agent permission parity)" - ), - } - } - - pre_tool_use = [ - HookMatcher( - matcher="Edit|Write|NotebookEdit", - hooks=[_snapshot_hook], - ), - HookMatcher( - matcher="Read", - hooks=[_validate_image_hook], - ), - ] - # Catch-all permission grant so background sub-agents (whose nested - # tool calls never reach can_use_tool) inherit foreground's tool - # permissions. Registered last so the snapshot/validator hooks still - # run for their tools; a deny from the validator wins over this allow. - if self.config.agent.background_agent_permissions: - pre_tool_use.append( - HookMatcher(matcher=None, hooks=[_grant_permission_hook]) - ) - - return { - "PreToolUse": pre_tool_use, - "PostToolUse": [ - HookMatcher( - matcher="ScheduleWakeup", - hooks=[_capture_wakeup_hook], - ), - ], - } - # Min/max delay the CLI's ScheduleWakeup enforces (clamped to [60, 3600]). _WAKEUP_MIN_DELAY = 60 _WAKEUP_MAX_DELAY = 3600 @@ -1433,53 +919,6 @@ async def _record_wakeup( ) return wakeup_id - @staticmethod - def _model_supports_legacy_enabled_thinking(model: str | None) -> bool: - # Claude 4.5 / 4.6 accept thinking.type="enabled" with budget_tokens. - # Newer models (4.7+) require thinking.type="adaptive" with effort. - if not model: - return False - m = model.lower() - return "4-5" in m or "4-6" in m - - @staticmethod - def _parse_thinking_config(value: str, model: str | None = None) -> dict | None: - """Parse thinking config string into SDK ThinkingConfig dict.""" - v = value.strip().lower() - if v == "disabled": - return {"type": "disabled"} - if v == "adaptive": - return {"type": "adaptive"} - if not AgentEngine._model_supports_legacy_enabled_thinking(model): - return {"type": "adaptive"} - budget_map = { - "max": 128_000, - "high": 64_000, - "medium": 32_000, - "low": 8_000, - } - if v in budget_map: - return {"type": "enabled", "budget_tokens": budget_map[v]} - try: - tokens = int(v) - return {"type": "enabled", "budget_tokens": tokens} - except ValueError: - logger.warning("Unknown thinking config '%s', using adaptive", value) - return {"type": "adaptive"} - - # Effort levels accepted per Claude model — substring-matched against the - # full model name so dated aliases (e.g. "claude-opus-4-8-20260528") resolve. - # Ordered most-specific to least-specific; first match wins. Mirrors the - # pattern used by MODEL_PRICING in nerve/db/usage.py. - _MODEL_EFFORT_LEVELS: dict[str, tuple[str, ...]] = { - "fable-5": ("low", "medium", "high", "xhigh", "max"), - "opus-4-8": ("low", "medium", "high", "xhigh", "max"), - "opus-4-7": ("low", "medium", "high", "xhigh", "max"), - "opus-4-6": ("low", "medium", "high", "max"), - "sonnet-4-6": ("low", "medium", "high"), - } - _EFFORT_RANK: tuple[str, ...] = ("low", "medium", "high", "xhigh", "max") - # Sources whose turns are sensing / triage work and use ``cron_effort`` # instead of the interactive ``effort``. Everything else (web, telegram, # wakeup, ...) is treated as interactive. @@ -1497,113 +936,35 @@ def _base_effort_for_source(source: str, effort: str, cron_effort: str) -> str: return cron_effort return effort - @staticmethod - def _effective_effort(value: str, model: str | None = None) -> str | None: - """Return ``value`` capped to the highest effort level ``model`` supports.""" - if value not in AgentEngine._EFFORT_RANK: - return None - allowed: tuple[str, ...] | None = None - if model: - m = model.lower() - for key, levels in AgentEngine._MODEL_EFFORT_LEVELS.items(): - if key in m: - allowed = levels - break - if not allowed or value in allowed: - return value - requested_rank = AgentEngine._EFFORT_RANK.index(value) - for level in reversed(AgentEngine._EFFORT_RANK[: requested_rank + 1]): - if level in allowed: - logger.debug( - "Capped effort %r to %r for model %r (model caps at %r)", - value, level, model, allowed[-1], - ) - return level - return None - # ------------------------------------------------------------------ # # SDK client lifecycle # # ------------------------------------------------------------------ # - @staticmethod - def _is_client_dead(client: ClaudeSDKClient) -> bool: - """Check if the client's underlying CLI process has terminated.""" - transport = getattr(client, "_transport", None) - if not transport: - return True - process = getattr(transport, "_process", None) - if process is None: - return True - return process.returncode is not None - - def _sdk_resume_file_exists(self, sdk_session_id: str) -> bool: - """Check whether Claude Code still has the conversation .jsonl - for the given SDK session ID on this filesystem. - - The CLI stores history at:: - - ~/.claude/projects//.jsonl - - where is the absolute cwd path with every '/' - replaced by '-'. The CLI resolves the cwd symlink before - encoding, so when the workspace is itself a symlink (e.g. the - Docker deployment's /root/nerve-workspace -> /Users/.../ - nerve-workspace) the history lives under the *realpath*-encoded - directory, not the symlink-encoded one. Check the realpath - first and fall back to the unresolved path for non-symlinked - layouts. - - If the file is gone (typically because the container's - /root/.claude was not bind-mounted and got wiped on restart), - passing --resume to the CLI fails with exit 1. - - Best-effort check: any unexpected error returns True so we still - attempt the resume and let the CLI surface the real error, - rather than masking unrelated bugs. - """ - try: - projects = os.path.expanduser("~/.claude/projects") - workspace = str(self.config.workspace) - bases = [os.path.realpath(workspace)] - if workspace not in bases: - bases.append(workspace) - for base in bases: - encoded = base.replace("/", "-") - jsonl = ( - projects + "/" + encoded - + "/" + sdk_session_id + ".jsonl" - ) - if os.path.isfile(jsonl): - return True - return False - except Exception as e: - logger.debug( - "Could not stat resume jsonl for %s: %s, assuming present", - sdk_session_id[:12], e, - ) - return True - async def _get_or_create_client( self, session_id: str, source: str, model: str | None, fork_from: str | None = None, - ) -> ClaudeSDKClient: + ) -> AgentClient: """Get an existing persistent client or create a new one. - If the session has a stored sdk_session_id, the new client is created - with resume=sdk_session_id so the CLI restores full conversation - context. - - If fork_from is set, creates the client with fork_session=True to - branch from the given SDK session. + Backend-agnostic orchestration: resolves the session's (sticky) + backend, validates/clears stale resume targets, freezes the + recall priors, renders the system prompt, and hands a + :class:`SessionSpec` to the backend. The backend owns everything + runtime-specific (options, hooks, subprocess). """ lock = self.sessions.get_lock(session_id) async with lock: client = self.sessions.get_client(session_id) - requested_model = model or self.config.agent.model + + # Session row first — backend resolution is sticky on it. + session = await self.db.get_session(session_id) + backend = self._backend_for(session, source) + requested_model = model or backend.default_model(source) + if client is not None: bound_model = self._session_models.get(session_id) - # Health check: verify the underlying CLI process is still alive - if self._is_client_dead(client): + # Health check: verify the underlying subprocess is alive + if not client.is_alive(): logger.warning( "Client process for session %s is dead, recreating", session_id, @@ -1615,9 +976,8 @@ async def _get_or_create_client( client = None elif bound_model is not None and bound_model != requested_model: # Model switched mid-session (e.g. the composer's picker - # moved from the Anthropic default to a local Ollama - # model). The CLI binds its model at connect time, so - # tear the client down and recreate it below. + # moved to a different model). Clients bind their model + # at connect time, so tear down and recreate below. logger.info( "Session %s model changed (%s → %s), recreating client", session_id, bound_model, requested_model, @@ -1634,8 +994,7 @@ async def _get_or_create_client( else: return client - # Check for stored SDK session ID for resume - session = await self.db.get_session(session_id) + # Check for stored native session ID for resume sdk_resume_id = session.get("sdk_session_id") if session else None try: session_meta = json.loads( @@ -1651,31 +1010,24 @@ async def _get_or_create_client( if session_meta.get("observed_model"): self._observed_models[session_id] = session_meta["observed_model"] - # For forks, use the source session's SDK ID + # For forks, use the source session's native ID if fork_from and not sdk_resume_id: sdk_resume_id = fork_from - # Defensive: verify the resume target's conversation .jsonl - # actually exists before passing it to the CLI. Claude Code - # stores conversation history in /root/.claude/projects/ - # /.jsonl. If that directory is - # not bind-mounted from the host, a container restart wipes - # the .jsonl files but the Nerve DB still holds the stale - # sdk_session_id; the CLI dies with "No conversation - # found with session ID" exit 1. - # - # When the file is missing, clear the stale id and start a - # fresh conversation rather than crashing the turn. Forks - # are exempt: the source session's context lives in the - # source's row, and a fresh fork has nothing to recover to. + # Defensive: let the backend verify the resume target is still + # materialized (claude: the conversation .jsonl under + # ~/.claude/projects; codex: no cheap check — create_client + # falls back and reports resume_dropped instead). Forks are + # exempt: the source session's context lives in the source's + # row, and a fresh fork has nothing to recover to. if sdk_resume_id and not fork_from: - if not self._sdk_resume_file_exists(sdk_resume_id): + if not backend.validate_resume_target( + sdk_resume_id, str(self.config.workspace), + ): logger.warning( - "Session %s resume target %s.jsonl is missing; " - "starting a fresh CLI conversation. This usually " - "means /root/.claude was not persisted across a " - "container restart.", - session_id, sdk_resume_id[:12], + "Session %s resume target %s is missing on the %s " + "backend; starting a fresh conversation.", + session_id, sdk_resume_id[:12], backend.name, ) await self.db.update_session_fields( session_id, {"sdk_session_id": None}, @@ -1684,7 +1036,7 @@ async def _get_or_create_client( if sdk_resume_id: logger.info( - "Resuming session %s with SDK session %s", + "Resuming session %s with native session %s", session_id, sdk_resume_id[:12], ) @@ -1712,26 +1064,27 @@ async def _get_or_create_client( logger.warning("Pre-recall failed: %s", e) # Resolve the prompt-cache TTL for this client build (cadence- - # aware; see nerve/agent/cache_policy.py). Recomputed on every - # client (re)build — client recycling at the idle sweep makes - # the policy naturally track cadence changes. - requested = model or self.config.agent.model - is_claude = not ( - self.config.ollama.enabled - and "claude" not in requested.lower() - ) - cache_ttl = await resolve_cache_ttl( - self.config.agent, self.db, session_id, source, requested, - session_meta=session_meta, is_claude_model=is_claude, - ) - logger.info( - "Session %s: prompt-cache TTL %s (source=%s, mode=%s)", - session_id, cache_ttl, source, - session_meta.get("cache_ttl_override") - or self.config.agent.cache_ttl, - ) - if session_meta.get("cache_ttl") != cache_ttl: - meta_updates["cache_ttl"] = cache_ttl + # aware; see nerve/agent/cache_policy.py). Claude-only — other + # backends manage caching natively (capability-gated). + cache_ttl = "5m" + if backend.capabilities.supports_cache_ttl: + is_claude = not ( + self.config.ollama.enabled + and "claude" not in requested_model.lower() + ) + cache_ttl = await resolve_cache_ttl( + self.config.agent, self.db, session_id, source, + requested_model, + session_meta=session_meta, is_claude_model=is_claude, + ) + logger.info( + "Session %s: prompt-cache TTL %s (source=%s, mode=%s)", + session_id, cache_ttl, source, + session_meta.get("cache_ttl_override") + or self.config.agent.cache_ttl, + ) + if session_meta.get("cache_ttl") != cache_ttl: + meta_updates["cache_ttl"] = cache_ttl if meta_updates and session: session_meta.update(meta_updates) @@ -1740,9 +1093,9 @@ async def _get_or_create_client( # Determine if this is a fork is_fork = fork_from is not None - # Create interactive tool handler for this session. - # Non-web sessions (telegram, cron, hook) cannot handle interactive - # tools — auto-deny them to prevent deadlocks. + # Create interactive hub for this session. + # Non-web sessions (telegram, cron, hook) cannot handle + # interactive pauses — auto-deny them to prevent deadlocks. is_interactive = source in ("web",) handler = InteractiveToolHandler( session_id=session_id, @@ -1752,24 +1105,68 @@ async def _get_or_create_client( ) register_handler(session_id, handler) - options = self._build_options( - session_id, source=source, model=model, + # Render the system prompt (engine-owned: identity files, + # frozen recall, skills; the tool list respects the backend's + # exclusions so the prompt never advertises a tool this + # session's MCP server doesn't serve). + system_prompt = build_system_prompt( + workspace=self.config.workspace, + session_id=session_id, + source=source, + timezone_name=self.config.timezone, recalled_memories=recalled_memories or None, - resume=sdk_resume_id, - fork_session=is_fork, - can_use_tool=handler.can_use_tool, + skill_summaries=self._collect_skill_summaries(), + excluded_tools=backend.excluded_tools(), + ) + + async def _record_wakeup_cb(sid: str, tool_input: dict) -> Any: + return await self._record_wakeup(self.db, sid, tool_input) + + spec = SessionSpec( + session_id=session_id, + source=source, + model=requested_model, + effort=self._base_effort_for_source( + source, self.config.agent.effort, + self.config.agent.cron_effort, + ), + system_prompt=system_prompt, + cwd=str(self.config.workspace), + resume_native_id=sdk_resume_id, + fork=is_fork, + interactive=handler, + snapshot=self._save_file_snapshot, + record_wakeup=_record_wakeup_cb, cache_ttl=cache_ttl, + max_turns=self.config.agent.max_turns, + idle_timeout=float(self.config.agent.cli_idle_timeout_seconds), ) - client = ClaudeSDKClient(options=options) - await client.connect() + + client = await backend.create_client(spec) + if getattr(client, "resume_dropped", False): + # The backend had to discard the stale native id (codex + # resume-miss recovery) — clear the persisted column; the + # fresh id is re-persisted at turn end. + await self.db.update_session_fields( + session_id, {"sdk_session_id": None}, + ) self.sessions.set_client(session_id, client) - # Watch the SDK stream between runs so autonomous CLI turns + self._session_backends[session_id] = backend.name + + # Stamp the sticky backend on first client build. + if not (session or {}).get("backend"): + await self.db.update_session_fields( + session_id, {"backend": backend.name}, + ) + + # Watch the stream between runs so autonomous runtime turns # (background task completions, Monitor events) stream to the - # UI instead of buffering invisibly. - self._start_idle_watcher(session_id, client, source) + # UI instead of buffering invisibly. Claude-only capability. + if backend.capabilities.supports_idle_stream: + self._start_idle_watcher(session_id, client, source) # Record connected_at and the resolved model - resolved_model = options.model + resolved_model = getattr(client, "model", "") or requested_model self._session_models[session_id] = resolved_model now = datetime.now(timezone.utc).isoformat() connected_at = session.get("connected_at") if session and sdk_resume_id else now @@ -1780,16 +1177,15 @@ async def _get_or_create_client( ) await self.db.update_session_fields(session_id, {"model": resolved_model}) - # A brand-new CLI process starts its cumulative cost counter at - # zero — zero the persisted baseline so the first turn's delta - # is exact. The reset inference in compute_turn_cost remains - # as a backstop for any recycle path that bypasses this (e.g. - # a `nerve restart` racing an in-flight turn). - await self._reset_cost_baseline(session_id) + # A brand-new runtime process starts its cumulative cost counter + # at zero — zero the persisted baseline so the first turn's + # delta is exact (claude-only; codex reports per-turn cost). + if backend.capabilities.cost_is_cumulative: + await self._reset_cost_baseline(session_id) logger.info( - "Created persistent client for session %s%s", - session_id, + "Created persistent %s client for session %s%s", + backend.name, session_id, " (resumed)" if sdk_resume_id and not is_fork else " (forked)" if is_fork else "", ) @@ -1919,24 +1315,24 @@ async def resume_session(self, session_id: str) -> dict: async def _process_tool_result( self, - block: ToolResultBlock, + event: ToolResult, session_id: str, - parent_tool_use_id: str | None, tool_results_map: dict[str, dict], ordered_blocks: list[dict], tool_calls_log: list[dict], active_subagents: dict[str, float], ) -> None: - """Process a single ToolResultBlock (shared by AssistantMessage and UserMessage paths).""" + """Process a normalized ToolResult event (shared by user runs and + autonomous-turn drains).""" result_content = ( - block.content - if isinstance(block.content, str) - else json.dumps(block.content, default=str) + event.content + if isinstance(event.content, str) + else json.dumps(event.content, default=str) ) - # Sanitize orphaned surrogates — CLI may truncate output mid-emoji + # Sanitize orphaned surrogates — runtimes may truncate output mid-emoji result_content = _sanitize_surrogates(result_content) - tool_use_id = getattr(block, "tool_use_id", None) - is_error = getattr(block, "is_error", False) + tool_use_id = event.tool_use_id + is_error = event.is_error tool_results_map[tool_use_id] = { "result": result_content, @@ -1955,7 +1351,7 @@ async def _process_tool_result( session_id, result_content, tool_use_id=tool_use_id, is_error=is_error or False, - parent_tool_use_id=parent_tool_use_id, + parent_tool_use_id=event.parent_tool_use_id, ) # Sub-agent lifecycle: emit complete event @@ -1983,10 +1379,6 @@ async def _process_tool_result( srv_name, mcp_tool = parsed try: duration = None - if tool_use_id in active_subagents: - # Sub-agent already popped above, but for - # regular MCP tools we don't track start time - pass # Auto-register unknown MCP servers on first use # (e.g. Claude Code plugins: "plugin_Notion_notion"). # Skip servers already registered at startup to avoid @@ -2075,157 +1467,127 @@ async def _track_serving_model( downgrade=downgrade, ) - async def _process_sdk_message( - self, session_id: str, message: Any, st: _TurnState, + async def _process_agent_event( + self, session_id: str, event: Any, st: _TurnState, ) -> bool: - """Process one SDK stream message: broadcast to the UI and + """Process one normalized agent event: broadcast to the UI and accumulate into ``st`` for DB persistence. Shared by ``_run_inner`` (user-initiated turns) and - ``_drain_pending_messages`` (autonomous CLI turns) so both paths - produce identical events and records. + ``_drain_pending_messages`` (autonomous runtime turns) so both + paths produce identical events and records. Backends translate + their native stream into these events (see + nerve/agent/backends/events.py). - Returns True when the message is a ResultMessage (turn complete). + Returns True when the event is a TurnCompleted (turn over). """ - # Early-capture sdk_session_id from the first message that carries - # it so it survives /stop cancellation (ResultMessage — the normal - # source — never arrives when the turn is interrupted). - if not st.sdk_session_id: - msg_sid = getattr(message, "session_id", None) - if msg_sid: - st.sdk_session_id = msg_sid - - if isinstance(message, AssistantMessage): + if isinstance(event, TextDelta): st.got_content = True - # Extract parent_tool_use_id — set when this message comes from - # a sub-agent (Task/Agent) rather than the main agent - parent_id = getattr(message, "parent_tool_use_id", None) - # Capture model from assistant message (more reliable than - # config). Main-agent messages only: sub-agents legitimately - # run different models (Agent tool `model` opt, built-in agent - # defaults), which must not pollute turn cost attribution or - # fire serving-model change events. - msg_model = getattr(message, "model", None) - if msg_model and parent_id is None: - st.last_model = msg_model - await self._track_serving_model(session_id, msg_model, st) - - for block in message.content: - if isinstance(block, TextBlock): - st.full_response_text += block.text - # Track ordered blocks for DB persistence - if st.ordered_blocks and st.ordered_blocks[-1].get("type") == "text": - st.ordered_blocks[-1]["content"] += block.text - else: - st.ordered_blocks.append({"type": "text", "content": block.text}) - await broadcaster.broadcast_token( - session_id, block.text, - parent_tool_use_id=parent_id, - ) + st.full_response_text += event.text + if st.ordered_blocks and st.ordered_blocks[-1].get("type") == "text": + st.ordered_blocks[-1]["content"] += event.text + else: + st.ordered_blocks.append({"type": "text", "content": event.text}) + await broadcaster.broadcast_token( + session_id, event.text, + parent_tool_use_id=event.parent_tool_use_id, + ) - elif ThinkingBlock is not None and isinstance( - block, ThinkingBlock, - ): - thinking = getattr(block, "thinking", "") or "" - if not thinking: - # Empty thinking block (e.g. Opus 4.7 with - # display="omitted", or simple queries on low - # effort). Nothing visible to render — never fall - # back to str(block) as that leaks the - # ThinkingBlock(...) repr into the UI. - continue - st.thinking_text += thinking - # Track ordered blocks for DB persistence - if st.ordered_blocks and st.ordered_blocks[-1].get("type") == "thinking": - st.ordered_blocks[-1]["content"] += thinking - else: - st.ordered_blocks.append({"type": "thinking", "content": thinking}) - await broadcaster.broadcast_thinking( - session_id, thinking, - parent_tool_use_id=parent_id, - ) + elif isinstance(event, ThinkingDelta): + st.got_content = True + st.thinking_text += event.text + if st.ordered_blocks and st.ordered_blocks[-1].get("type") == "thinking": + st.ordered_blocks[-1]["content"] += event.text + else: + st.ordered_blocks.append({"type": "thinking", "content": event.text}) + await broadcaster.broadcast_thinking( + session_id, event.text, + parent_tool_use_id=event.parent_tool_use_id, + ) - elif isinstance(block, ToolUseBlock): - tool_input = getattr(block, "input", {}) - tool_name = getattr(block, "name", None) or str(block) - tool_use_id = getattr(block, "id", None) - await broadcaster.broadcast_tool_use( - session_id, tool_name, tool_input, - tool_use_id=tool_use_id, - parent_tool_use_id=parent_id, - ) - # Track sub-agent lifecycle. Claude Code 2.1.x renamed - # the subagent-spawning tool from ``Task`` → ``Agent`` - # (and introduced separate ``TaskCreate``/``TaskUpdate`` - # /etc. tools for in-session todo tracking). Match both - # names so old session history still opens panels on - # replay. - if tool_name in ("Task", "Agent") and tool_use_id: - st.active_subagents[tool_use_id] = asyncio.get_event_loop().time() - await broadcaster.broadcast_subagent_start( - session_id, - tool_use_id=tool_use_id, - subagent_type=str(tool_input.get("subagent_type", tool_input.get("model", "agent"))), - description=str(tool_input.get("description", "")), - model=str(tool_input.get("model", "")) or None, - ) - # Track dynamic workflows. A ``Workflow`` tool call spawns - # a background runtime; later task_* system messages carry - # its progress tree keyed by this tool_use_id. Register it - # now so _handle_system_message can recognize those events - # even before the first ``workflow_progress`` payload. - if tool_name == "Workflow" and tool_use_id: - self._workflows.setdefault(session_id, {})[tool_use_id] = { - "name": self._derive_workflow_name(tool_input), - "snapshot": None, - } - st.tool_calls_log.append({ - "tool": tool_name, - "input": tool_input, - "tool_use_id": tool_use_id, - }) - st.ordered_blocks.append({ - "type": "tool_call", - "tool": tool_name, - "input": tool_input, - "tool_use_id": tool_use_id, - }) + elif isinstance(event, ToolUse): + st.got_content = True + await broadcaster.broadcast_tool_use( + session_id, event.name, event.input, + tool_use_id=event.tool_use_id, + parent_tool_use_id=event.parent_tool_use_id, + ) + # Track dynamic workflows. A ``Workflow`` tool call spawns + # a background runtime; later task_* system events carry + # its progress tree keyed by this tool_use_id. Register it + # now so _handle_system_event can recognize those events + # even before the first ``workflow_progress`` payload. + if event.name == "Workflow" and event.tool_use_id: + self._workflows.setdefault(session_id, {})[event.tool_use_id] = { + "name": self._derive_workflow_name(event.input), + "snapshot": None, + } + st.tool_calls_log.append({ + "tool": event.name, + "input": event.input, + "tool_use_id": event.tool_use_id, + }) + st.ordered_blocks.append({ + "type": "tool_call", + "tool": event.name, + "input": event.input, + "tool_use_id": event.tool_use_id, + }) - elif isinstance(block, ToolResultBlock): - await self._process_tool_result( - block, session_id, parent_id, - st.tool_results_map, st.ordered_blocks, - st.tool_calls_log, st.active_subagents, - ) + elif isinstance(event, SubagentStarted): + st.active_subagents[event.tool_use_id] = asyncio.get_event_loop().time() + await broadcaster.broadcast_subagent_start( + session_id, + tool_use_id=event.tool_use_id, + subagent_type=event.subagent_type, + description=event.description, + model=event.model, + ) - elif isinstance(message, UserMessage): - parent_id = getattr(message, "parent_tool_use_id", None) - content = getattr(message, "content", []) - if isinstance(content, list): - for block in content: - if isinstance(block, ToolResultBlock): - await self._process_tool_result( - block, session_id, parent_id, - st.tool_results_map, st.ordered_blocks, - st.tool_calls_log, st.active_subagents, - ) + elif isinstance(event, ToolResult): + await self._process_tool_result( + event, session_id, + st.tool_results_map, st.ordered_blocks, + st.tool_calls_log, st.active_subagents, + ) + + elif isinstance(event, ModelObserved): + # Main-agent serving-model observation (backends already gate + # out sub-agent models). More reliable than config. + st.last_model = event.model + await self._track_serving_model(session_id, event.model, st) - elif isinstance(message, SystemMessage): + elif isinstance(event, SystemEvent): # Task lifecycle events (task_started/task_updated/ # task_notification) drive the background-task chips in the UI. - # Other subtypes (init, status, ...) are informational only. - await self._handle_system_message(session_id, message) - - elif isinstance(message, ResultMessage): - if message.usage: - st.last_usage = message.usage - st.sdk_session_id = message.session_id + # Other subtypes (init, codex_plan, ...) are informational only. + await self._handle_system_event(session_id, event) + + elif isinstance(event, TurnCompleted): + if event.usage is not None: + st.last_usage = event.usage.to_anthropic_shape() + if event.native_session_id: + st.sdk_session_id = event.native_session_id + if event.model and not st.last_model: + st.last_model = event.model st.result_meta = { - "total_cost_usd": getattr(message, "total_cost_usd", None), - "duration_ms": getattr(message, "duration_ms", None), - "duration_api_ms": getattr(message, "duration_api_ms", None), - "num_turns": getattr(message, "num_turns", None), + "total_cost_usd": event.total_cost_usd, + "duration_ms": event.duration_ms, + "duration_api_ms": event.duration_api_ms, + "num_turns": event.num_turns, + "context_window": event.context_window, + "status": event.status, } + if event.status == "failed" and event.error: + # Failed turns still complete: surface the error inline so + # the conversation shows what happened (the runtime's + # transport stays healthy — this is a model/API failure). + note = f"⚠️ Turn failed: {event.error}" + st.full_response_text += ( + ("\n\n" + note) if st.full_response_text else note + ) + st.ordered_blocks.append({"type": "text", "content": note}) + await broadcaster.broadcast_token(session_id, note) return True return False @@ -2233,30 +1595,30 @@ async def _process_sdk_message( # CLI task statuses that mean "no longer running". _BG_TERMINAL_STATUSES = frozenset({"completed", "failed", "stopped", "killed"}) - async def _handle_system_message( - self, session_id: str, message: Any, + async def _handle_system_event( + self, session_id: str, event: SystemEvent, ) -> None: - """Track CLI background-task lifecycle events and update the UI. + """Track runtime background-task lifecycle events and update the UI. - The CLI emits ``system`` messages for background work (Bash/Agent - ``run_in_background``, Monitor watches): + The Claude CLI emits ``system`` messages for background work + (Bash/Agent ``run_in_background``, Monitor watches): - ``task_started`` — task spawned (description, task_type) - ``task_progress`` — periodic usage updates - ``task_updated`` — status patches - ``task_notification`` — task settled (completed/failed/stopped) - These replace the old regex-based output-file watcher: the chips in - the UI now reflect what the CLI actually tracks. + Backends merge any legacy top-level fields into ``event.data`` so + this handler reads one dict. """ - subtype = getattr(message, "subtype", "") or "" + subtype = event.subtype if subtype not in ( "task_started", "task_progress", "task_updated", "task_notification", ): return - data = getattr(message, "data", None) or {} - task_id = data.get("task_id") or getattr(message, "task_id", None) + data = event.data or {} + task_id = data.get("task_id") if not task_id: return @@ -2272,16 +1634,14 @@ async def _handle_system_message( changed = True if subtype == "task_started": entry["label"] = ( - data.get("description") - or getattr(message, "description", "") - or entry["label"] or task_id + data.get("description") or entry["label"] or task_id ) task_type = str(data.get("task_type") or "") entry["tool"] = "Agent" if "agent" in task_type else "Bash" entry["status"] = "running" elif subtype == "task_progress": # Only useful for backfilling a label if task_started was missed. - desc = data.get("description") or getattr(message, "description", "") + desc = data.get("description") if desc and not entry["label"]: entry["label"] = desc else: @@ -2294,7 +1654,7 @@ async def _handle_system_message( else: changed = False elif subtype == "task_notification": - status = str(data.get("status") or getattr(message, "status", "") or "") + status = str(data.get("status") or "") entry["status"] = ( "done" if status in ("completed", "stopped", "") else "failed" ) @@ -2303,10 +1663,10 @@ async def _handle_system_message( # Dynamic-workflow progress. A workflow task is recognized either by # its tool_use_id (captured when the ``Workflow`` tool streamed) or by - # the presence of a ``workflow_progress`` tree on the message. We emit + # the presence of a ``workflow_progress`` tree on the event. We emit # a dedicated event so the UI can render a live phase/agent panel — # independent of the coarse background-task chip above. - tool_use_id = data.get("tool_use_id") or getattr(message, "tool_use_id", None) + tool_use_id = data.get("tool_use_id") wf_reg = self._workflows.get(session_id) or {} wp = data.get("workflow_progress") task_type = str(data.get("task_type") or "") @@ -2325,7 +1685,7 @@ async def _handle_system_message( tool_use_id, {"name": "Workflow", "snapshot": None}, )["name"] = str(wf_name) await self._emit_workflow_progress( - session_id, tool_use_id, subtype, data, message, wp, + session_id, tool_use_id, subtype, data, wp, ) if changed: @@ -2341,7 +1701,6 @@ async def _emit_workflow_progress( tool_use_id: str, subtype: str, data: dict, - message: Any, wp: Any, ) -> None: """Build, cache, broadcast (and on terminal, persist) a workflow @@ -2363,15 +1722,10 @@ async def _emit_workflow_progress( "agentCount": prev.get("agentCount", 0), } - status = self._workflow_status(subtype, data, message) + status = self._workflow_status(subtype, data) snapshot["name"] = cached.get("name") or "Workflow" snapshot["status"] = status - summary = ( - data.get("summary") - or data.get("description") - or getattr(message, "summary", "") - or getattr(message, "description", "") - ) + summary = data.get("summary") or data.get("description") if summary: snapshot["summary"] = str(summary)[:2000] @@ -2385,7 +1739,7 @@ async def _emit_workflow_progress( logger.debug("merge_workflow_into_call failed for %s: %s", tool_use_id, e) @staticmethod - def _workflow_status(subtype: str, data: dict, message: Any) -> str: + def _workflow_status(subtype: str, data: dict) -> str: """Map a task_* system message to a workflow status string (running / completed / failed / stopped).""" if subtype in ("task_started", "task_progress"): @@ -2397,9 +1751,7 @@ def _workflow_status(subtype: str, data: dict, message: Any) -> str: return "stopped" return s or "running" if subtype == "task_notification": - return str( - data.get("status") or getattr(message, "status", "") or "completed" - ) + return str(data.get("status") or "completed") return "running" @staticmethod @@ -2531,12 +1883,18 @@ async def _finalize_turn( connected_at=await self.get_client_connected_at_async(session_id), ) - # Persist usage for context bar on session switch - max_context = ( - 1_048_576 - if self.config.agent.context_1m_enabled_for(st.last_model) - else 200_000 - ) + # Persist usage for context bar on session switch. Backends that + # report the serving model's context window (codex, via + # thread/tokenUsage/updated) override the Anthropic-derived value. + reported_window = (st.result_meta or {}).get("context_window") + if reported_window: + max_context = int(reported_window) + else: + max_context = ( + 1_048_576 + if self.config.agent.context_1m_enabled_for(st.last_model) + else 200_000 + ) num_turns = (st.result_meta or {}).get("num_turns") or 1 if st.last_usage: usage_data = { @@ -2557,39 +1915,51 @@ async def _finalize_turn( web_search = server_tool.get("web_search_requests", 0) web_fetch = server_tool.get("web_fetch_requests", 0) - # Calculate per-turn cost. - # NOTE: The SDK's total_cost_usd is *cumulative* per CLI client - # process, NOT per-invocation. We track the last known - # cumulative value in session metadata and compute the delta - # for this turn. The counter resets whenever the client is - # recycled (idle sweep, oneshot cron teardown, restart, model - # switch) — compute_turn_cost detects the reset and attributes - # the new cumulative to this turn instead of recording $0. + # Calculate per-turn cost. Semantics depend on the backend + # (cost_is_cumulative capability): + # + # * Claude: the SDK's total_cost_usd is *cumulative* per CLI + # client process, NOT per-invocation. We track the last + # known cumulative value in session metadata and compute the + # delta for this turn. The counter resets whenever the + # client is recycled — compute_turn_cost detects the reset + # and attributes the new cumulative to this turn. + # * Codex: the backend pre-computes THIS turn's cost from its + # pricing table (None when the model has no entry — recorded + # as $0 with tokens intact, never estimated) and the + # cumulative bookkeeping must stay untouched. from nerve.db.usage import compute_turn_cost, extract_cache_ttl_split sdk_cost = (st.result_meta or {}).get("total_cost_usd") current_session_cost = ( session_record.get("total_cost_usd", 0) if session_record else 0 ) or 0 - prev_cumulative = meta.get("_sdk_cumulative_cost", 0) or 0 - turn_cost, cost_source = compute_turn_cost( - sdk_cost, prev_cumulative, st.last_usage, model=st.last_model, - ) - if cost_source == "sdk_reset": - logger.info( - "SDK cost counter reset for %s (%.4f < %.4f) — client " - "recycle; attributing new cumulative to this turn", - session_id, sdk_cost, prev_cumulative, - ) - elif cost_source == "estimate_backstop": - logger.warning( - "SDK reported no turn cost (cumulative %.4f, prev %.4f) " - "despite token traffic for %s — using token-based " - "estimate $%.4f", - sdk_cost, prev_cumulative, session_id, turn_cost, + cost_is_cumulative = self._backend_for_live_session( + session_id, + ).capabilities.cost_is_cumulative + if cost_is_cumulative: + prev_cumulative = meta.get("_sdk_cumulative_cost", 0) or 0 + turn_cost, cost_source = compute_turn_cost( + sdk_cost, prev_cumulative, st.last_usage, model=st.last_model, ) - if sdk_cost is not None: - meta["_sdk_cumulative_cost"] = sdk_cost + if cost_source == "sdk_reset": + logger.info( + "SDK cost counter reset for %s (%.4f < %.4f) — client " + "recycle; attributing new cumulative to this turn", + session_id, sdk_cost, prev_cumulative, + ) + elif cost_source == "estimate_backstop": + logger.warning( + "SDK reported no turn cost (cumulative %.4f, prev %.4f) " + "despite token traffic for %s — using token-based " + "estimate $%.4f", + sdk_cost, prev_cumulative, session_id, turn_cost, + ) + if sdk_cost is not None: + meta["_sdk_cumulative_cost"] = sdk_cost + else: + turn_cost = float(sdk_cost) if sdk_cost is not None else 0.0 + cost_source = "backend" # Save metadata (includes _sdk_cumulative_cost update) await self.db.update_session_metadata(session_id, meta) @@ -2725,60 +2095,6 @@ async def run( "is_running": False, }) - @staticmethod - async def _iter_response_with_timeout( - client: Any, - session_id: str, - idle_timeout: float, - ): - """Iterate ``client.receive_response()`` with a per-message idle timeout. - - The Claude Agent SDK's ``receive_response()`` async generator can - block indefinitely if the underlying CLI subprocess hangs (stuck - Anthropic API request, broken stdio pipe, etc.). Without a timeout - the engine has no way to notice — ``is_running`` stays True, the - per-session lock stays held, queued user messages back up forever. - - Wrapping each ``__anext__()`` await in ``asyncio.wait_for`` detects - a hung CLI when no SDK message of any kind (assistant chunk, tool - call, tool result, ResultMessage) arrives within ``idle_timeout`` - seconds. The iterator is closed and ``asyncio.TimeoutError`` is - raised so the existing CLI-crash retry path in ``_run_inner`` can - take over. - - The timeout is per-message, not per-turn, so legitimate long tool - calls (e.g. a Bash command with ``timeout=600000`` ms) don't trip - it as long as they emit ``tool_use``/``tool_result`` chunks - between waits. - - ``idle_timeout <= 0`` disables the timeout entirely (kept for - belt-and-suspenders ops who want the old behaviour back). - """ - response_iter = client.receive_response() - try: - while True: - try: - if idle_timeout and idle_timeout > 0: - message = await asyncio.wait_for( - response_iter.__anext__(), - timeout=idle_timeout, - ) - else: - message = await response_iter.__anext__() - except StopAsyncIteration: - return - except asyncio.TimeoutError: - logger.warning( - "CLI idle timeout (%ds) for session %s — no SDK " - "message received; treating CLI as hung", - idle_timeout, session_id, - ) - raise - yield message - finally: - with contextlib.suppress(Exception): - await response_iter.aclose() - async def _run_inner( self, session_id: str, @@ -2888,23 +2204,17 @@ async def _run_inner( # New user turn: settled background-task chips are stale now. self._prune_bg_tasks(session_id) - # Send message — the client preserves conversation history internally - # Escape slash-prefixed messages so Claude Code CLI doesn't - # intercept them as built-in slash commands. Registered bot - # commands (/stop, /new, etc.) are handled upstream — anything - # that reaches here should go straight to the LLM. - query_text = user_message - if query_text and query_text.startswith("/"): - query_text = "\u200b" + query_text - + # New user turn: settled background-task chips are stale now. + # (moved below drain in original flow — kept semantically here) # Trailing wall-clock reminder. Precise time deliberately does # NOT live in the system prompt (only the date does): a # per-build timestamp there changes the prompt bytes on every # client rebuild, invalidating the prompt cache for the entire # conversation replay (see nerve/agent/cache_policy.py). As a - # message-tail reminder it is fresher \u2014 per turn instead of per - # client build \u2014 and costs nothing cache-wise. Not persisted to + # message-tail reminder it is fresher — per turn instead of per + # client build — and costs nothing cache-wise. Not persisted to # the DB message (db_text above), so the UI stays clean. + query_text = user_message if query_text or images: _time_note = ( "Current time: " @@ -2915,62 +2225,25 @@ async def _run_inner( f"{query_text}\n\n{_time_note}" if query_text else _time_note ) - # Build multi-modal content blocks once (reused on retry) - if images: - content_blocks: list[dict[str, Any]] = [] - if query_text: - content_blocks.append({"type": "text", "text": query_text}) - for img in images: - # Text files are inlined as text context blocks - if img.get("type") == "text_file": - fname = img.get("filename", "file") - content = img.get("content", "") - content_blocks.append({ - "type": "text", - "text": f"--- Attached: {fname} ---\n{content}", - }) - continue - - # PDFs use "document" content block; images use "image" - block_type = "document" if img["media_type"] == "application/pdf" else "image" + # Backend-neutral turn input: attachments pass through raw; + # each backend converts to its native shape (Anthropic content + # blocks / codex UserInput items) and applies its own escaping + # rules (e.g. the Claude CLI's slash-command interception). + turn_input = TurnInput(text=query_text, images=images) - # Validate image data before sending — prevent poisoning - # the CLI's conversation with unprocessable images. - if block_type == "image": - img_error = _validate_image_data( - img["data"], img["media_type"], - ) - if img_error: - logger.warning( - "Skipping invalid image for session %s: %s", - session_id[:8], img_error, - ) - # Inject as text so the agent knows what happened - content_blocks.append({ - "type": "text", - "text": f"[Image skipped: {img_error}]", - }) - continue - - content_blocks.append({ - "type": block_type, - "source": { - "type": img["type"], - "media_type": img["media_type"], - "data": img["data"], - }, - }) - - # Send query + read response, with auto-retry on CLI crash. - # The CLI may crash during query (CLIConnectionError) or during - # response reading (generic Exception from the SDK reader task). + # Send query + read response, with auto-retry on runtime crash. + # The runtime may die during start_turn (TransportDiedError) or + # during response reading (generic Exception from the stream). # Retry once with a fresh client if no content was received yet. # - # The whole turn (query + every streamed message including tool + # The whole turn (query + every streamed event including tool # calls) is wrapped in ``lf_attrs`` so all OTEL spans emitted by - # the SDK carry our session_id / tags. The wrap is a no-op when - # Langfuse is disabled. - _effective_model = model or self.config.agent.model + # the Claude SDK carry our session_id / tags. The wrap is a + # no-op when Langfuse is disabled (and for codex turns, which + # are not instrumented — known v1 gap). + _effective_model = ( + self._session_models.get(session_id) or model or "" + ) _lf_tags = [f"source:{source}", f"model:{_effective_model}"] if channel: _lf_tags.append(f"channel:{channel}") @@ -2989,22 +2262,12 @@ async def _run_inner( ): for _attempt in range(2): try: - if images: - async def _image_prompt(): - yield { - "type": "user", - "message": {"role": "user", "content": content_blocks}, - "parent_tool_use_id": None, - } - - await client.query(_image_prompt()) - else: - await client.query(query_text) - except CLIConnectionError as _qerr: + await client.start_turn(turn_input) + except TransportDiedError as _qerr: if _attempt > 0: raise logger.warning( - "CLI dead for session %s (query phase): %s — retrying", + "Agent runtime dead for session %s (query phase): %s — retrying", session_id, _qerr, ) self._stop_idle_watcher(session_id) @@ -3016,33 +2279,30 @@ async def _image_prompt(): ) continue # retry the query - # Read response — may raise if CLI crashes mid-stream - # or hangs idle for longer than cli_idle_timeout_seconds - # (see _iter_response_with_timeout). + # Read response — may raise if the runtime crashes + # mid-stream or hangs idle beyond the per-message idle + # timeout (enforced inside the client's receive_turn). try: - async for message in AgentEngine._iter_response_with_timeout( - client, session_id, - self.config.agent.cli_idle_timeout_seconds, - ): - done = await self._process_sdk_message( - session_id, message, st, + async for event in client.receive_turn(): + done = await self._process_agent_event( + session_id, event, st, ) if done: - # receive_response() also stops after the - # ResultMessage; the explicit break keeps - # the invariant local. + # receive_turn also stops after the terminal + # event; the explicit break keeps the + # invariant local. break except asyncio.CancelledError: raise # propagate to outer handler except Exception as _recv_err: - # CLI crashed during response reading. + # Runtime crashed during response reading. # Retry only if we haven't received any content yet # (otherwise we'd produce duplicate/garbled output). if st.got_content or _attempt > 0: raise logger.warning( - "CLI crashed for session %s during response " + "Agent runtime crashed for session %s during response " "(no content yet): %s — retrying with fresh client", session_id, _recv_err, ) @@ -3065,9 +2325,17 @@ async def _image_prompt(): ) # --- Critical cleanup first (must succeed for resume) ---------- - # Persist sdk_session_id so the session can be resumed later. - # For new sessions the DB still has NULL because mark_active() - # was called before the SDK emitted any messages. + # Persist the native session id so the session can be resumed + # later. For new sessions the DB still has NULL because + # mark_active() ran before the runtime emitted anything; the + # normal source (TurnCompleted) never arrives on an + # interrupted turn, so fall back to the live client's early- + # captured id. + if not st.sdk_session_id: + _live = self.sessions.get_client(session_id) + if _live is not None: + with contextlib.suppress(Exception): + st.sdk_session_id = _live.native_session_id if st.sdk_session_id: await self.db.update_session_fields( session_id, {"sdk_session_id": st.sdk_session_id}, @@ -3181,27 +2449,6 @@ async def _image_prompt(): # How often the idle watcher probes the SDK buffer (seconds). _IDLE_STREAM_POLL_SECONDS = 0.5 - @staticmethod - def _sdk_message_stream(client: Any) -> Any | None: - """Return the SDK client's internal message receive stream. - - Private-API access (``client._query._message_receive``), pinned to - the bundled SDK version. Callers degrade gracefully (drain and - watcher become no-ops) when the attribute shape changes. - """ - return getattr(getattr(client, "_query", None), "_message_receive", None) - - @classmethod - def _sdk_buffer_used(cls, client: Any) -> int: - """Number of unread messages in the SDK client's buffer (0 on error).""" - stream = cls._sdk_message_stream(client) - if stream is None: - return 0 - try: - return int(stream.statistics().current_buffer_used) - except Exception: - return 0 - async def _drain_pending_messages( self, session_id: str, @@ -3211,27 +2458,29 @@ async def _drain_pending_messages( manage_framing: bool = False, first_content_timeout: float = 30.0, ) -> int: - """Drain SDK messages that arrived outside an active ``run()``. + """Drain agent events that arrived outside an active ``run()``. - Autonomous CLI turns are routed through the same pipeline as user + Autonomous runtime turns (Claude CLI background-task + continuations) are routed through the same pipeline as user turns: blocks broadcast live, assistant message persisted with a leading ``{"type": "auto"}`` marker, usage recorded, ``done`` emitted. Standalone task lifecycle events update the background- task chips without opening a turn. Never parks while no turn is open (only consumes what's already - buffered), so the pre-query call inside ``run()`` cannot hang on - an idle CLI. The CLI emits a ``system/init`` message when it - starts processing a turn, so an ``init`` in the buffer means - content IS coming (the model call is in flight) — the drain opens + buffered via ``try_receive_idle_events``), so the pre-query call + inside ``run()`` cannot hang on an idle runtime. A buffered + ``init`` system event means content IS coming — the drain opens the turn and waits up to ``first_content_timeout`` for the first - content message (model latency can be several seconds). If - nothing arrives the empty turn is dropped without persisting and + content event. If nothing arrives the empty turn is dropped and the watcher's next poll picks the content up instead. Once content flows, the wait uses the same idle timeout as a normal run; on that timeout the partial turn is persisted and ``asyncio.TimeoutError`` propagates so the caller can apply - hung-CLI treatment. + hung-runtime treatment. + + Backends without an idle stream (codex) return ``None`` from the + probe immediately — the drain is a no-op for them. Caller must hold the per-session run lock. ``manage_framing`` controls session-level run framing (mark_running/session_running/ @@ -3240,13 +2489,6 @@ async def _drain_pending_messages( Returns the number of completed autonomous turns processed. """ - stream = self._sdk_message_stream(client) - if stream is None: - return 0 - - from claude_agent_sdk._errors import MessageParseError - from claude_agent_sdk._internal.message_parser import parse_message - idle_timeout = self.config.agent.cli_idle_timeout_seconds turns = 0 st: _TurnState | None = None @@ -3298,22 +2540,15 @@ async def _close_turn() -> None: while True: if st is None: # No turn open — only consume what's already buffered. - try: - data = stream.receive_nowait() - except anyio.WouldBlock: - break - except (anyio.EndOfStream, anyio.ClosedResourceError): - break + batch = client.try_receive_idle_events() + if batch is None: + break # nothing buffered / stream closed else: - # Turn in flight — the CLI is producing; park for the - # next message. Before the first content message the + # Turn in flight — the runtime is producing; park for + # the next event batch. Before the first content the # wait is capped by first_content_timeout (init arrives # seconds before the model's first output); after that - # it matches a normal run's idle timeout. NOTE: a - # timeout cancels the parked receive, which can in - # theory drop one in-flight message — acceptable on - # both timeout paths (empty turn → watcher re-drains; - # hung CLI → client discarded). + # it matches a normal run's idle timeout. waiting_first_content = not st.got_content if waiting_first_content: park_timeout: float | None = first_content_timeout @@ -3324,9 +2559,7 @@ async def _close_turn() -> None: else None ) try: - data = await asyncio.wait_for( - stream.receive(), timeout=park_timeout, - ) + batch = await client.receive_idle_events(park_timeout) except asyncio.TimeoutError: if waiting_first_content: logger.info( @@ -3339,77 +2572,80 @@ async def _close_turn() -> None: break logger.warning( "Autonomous turn idle timeout (%ss) for session %s " - "— persisting partial turn and flagging CLI as hung", + "— persisting partial turn and flagging runtime as hung", idle_timeout, session_id, ) st.full_response_text += ( - "\n\n[Background turn interrupted: CLI went silent]" + "\n\n[Background turn interrupted: runtime went silent]" if st.full_response_text - else "[Background turn interrupted: CLI went silent]" + else "[Background turn interrupted: runtime went silent]" ) await _close_turn() raise - except (anyio.EndOfStream, anyio.ClosedResourceError): + if batch is None: logger.warning( - "SDK stream ended mid-autonomous-turn for session %s", + "Agent stream ended mid-autonomous-turn for session %s", session_id, ) await _close_turn() break - mtype = data.get("type") if isinstance(data, dict) else None - if mtype == "end": - # Reader sentinel — stream is closed. - await _close_turn() - break - if mtype == "error": - logger.error( - "SDK stream error during autonomous drain for %s: %s", - session_id, data.get("error"), - ) - await _close_turn() - break + if not batch: + continue # unparseable / skip-and-continue payload + + # A ModelObserved that precedes content in the same batch + # (autonomous AssistantMessage without init framing) must + # not be lost — hold it and replay once the turn opens. + pending_model: ModelObserved | None = None + + for event in batch: + if isinstance(event, SystemEvent) and st is None: + if event.subtype == "init": + # The runtime emits ``init`` when it starts + # processing a turn — an autonomous continuation + # is underway; open the turn and park for content. + await _open_turn() + else: + # Task lifecycle events between turns — chips only. + await self._handle_system_event(session_id, event) + continue - try: - message = parse_message(data) - except MessageParseError as pe: - logger.warning( - "Unparseable SDK message during drain for %s: %s", - session_id, pe, - ) - continue - if message is None: - continue + if isinstance(event, TurnCompleted) and st is None: + # Stray terminal event with no preceding content + # (e.g. a prior drain timed out mid-turn). Consume + # it so it can't desync the next turn; nothing to + # render. + logger.info( + "Consumed stray TurnCompleted during drain for %s", + session_id, + ) + continue - if isinstance(message, SystemMessage) and st is None: - if getattr(message, "subtype", "") == "init": - # The CLI emits ``init`` when it starts processing - # a turn — an autonomous continuation is underway; - # open the turn and park for its content. - await _open_turn() - else: - # Task lifecycle events between turns — chips only. - await self._handle_system_message(session_id, message) - continue + if st is None and isinstance(event, ModelObserved): + pending_model = event + continue - if isinstance(message, (AssistantMessage, UserMessage)): - await _open_turn() - elif isinstance(message, ResultMessage) and st is None: - # Stray result with no preceding content (e.g. a prior - # drain timed out mid-turn). Consume it so it can't - # desync the next receive_response(); nothing to render. - logger.info( - "Consumed stray ResultMessage during drain for %s", - session_id, - ) - continue + if st is None and isinstance( + event, + (TextDelta, ThinkingDelta, ToolUse, ToolResult, + SubagentStarted), + ): + await _open_turn() + if pending_model is not None: + await self._process_agent_event( + session_id, pending_model, st, + ) + pending_model = None - if st is not None: - turn_done = await self._process_sdk_message( - session_id, message, st, - ) - if turn_done: - await _close_turn() + if st is not None: + turn_done = await self._process_agent_event( + session_id, event, st, + ) + if turn_done: + await _close_turn() + # A ModelObserved whose batch never produced content is + # dropped with it — an empty assistant message carries + # nothing worth persisting. except asyncio.CancelledError: # /stop (or teardown) cancelled the drain mid-turn — persist @@ -3454,7 +2690,14 @@ async def _close_turn() -> None: def _start_idle_watcher( self, session_id: str, client: Any, source: str, ) -> None: - """Spawn the idle stream watcher for a freshly connected client.""" + """Spawn the idle stream watcher for a freshly connected client. + + No-op for backends without an idle stream (codex has no + autonomous turns — the watcher would only burn a poll loop). + """ + backend = self._backend_for_live_session(session_id) + if not backend.capabilities.supports_idle_stream: + return self._stop_idle_watcher(session_id) channel = self._active_channel.get(session_id) self._idle_watchers[session_id] = asyncio.create_task( @@ -3496,8 +2739,8 @@ async def _idle_stream_watcher( return # replaced/discarded — new client gets a new watcher if self.sessions.is_running(session_id): continue # run() owns the stream right now - if self._sdk_buffer_used(client) <= 0: - if self._is_client_dead(client): + if client.buffer_used() <= 0: + if not client.is_alive(): return continue @@ -3654,7 +2897,7 @@ async def run_cron( session_id=session_id, user_message=prompt, source="cron", - model=model or self.config.agent.cron_model, + model=model, # backend default_model(source) fills cron defaults ) finally: await self._teardown_oneshot_client(session_id) @@ -3692,7 +2935,7 @@ async def run_persistent_cron( session_id=session_id, user_message=prompt, source="cron", - model=model or self.config.agent.cron_model, + model=model, # backend default_model(source) fills cron defaults ) finally: # The session is reused by the next run (until rotation), which @@ -3721,7 +2964,7 @@ async def run_hook( session_id=session_id, user_message=prompt, source="hook", - model=model or self.config.agent.cron_model, + model=model, # backend default_model(source) fills cron defaults ) finally: await self._teardown_oneshot_client(session_id) diff --git a/nerve/agent/interactive.py b/nerve/agent/interactive.py index 40c71aa..4477a7d 100644 --- a/nerve/agent/interactive.py +++ b/nerve/agent/interactive.py @@ -1,14 +1,16 @@ """Interactive tool handler — pauses agent execution for user input. -Implements the `can_use_tool` callback for the Claude Agent SDK. -Interactive tools (AskUserQuestion, ExitPlanMode, EnterPlanMode) pause -the agent mid-turn, broadcast to the UI via WebSocket, and resume once -the user responds. - -File-modifying tools (Edit, Write, NotebookEdit) trigger a pre-execution -file snapshot to capture original content for diff computation. - -All other tools are auto-approved with zero overhead. +Backend-neutral pause/approve machinery. A backend adapter (e.g. +:class:`nerve.agent.backends.claude.ClaudeToolPermissions` for the SDK's +``can_use_tool`` callback, or the Codex approval-request handlers) +translates its runtime's permission surface into +:meth:`InteractiveToolHandler.request_interaction` / +:meth:`InteractiveToolHandler.request_approval` calls; the hub broadcasts +to the UI via WebSocket ``interaction`` events and resumes once the user +responds. + +No agent-runtime types (``claude_agent_sdk`` / Codex protocol) may be +imported here — this module sits on the engine side of the backend seam. """ from __future__ import annotations @@ -20,25 +22,27 @@ from typing import Any, Callable, Coroutine from uuid import uuid4 -from claude_agent_sdk.types import ( - PermissionResult, - PermissionResultAllow, - PermissionResultDeny, - ToolPermissionContext, -) - logger = logging.getLogger(__name__) # Default timeout for interactive tool waits (1 hour) INTERACTION_TIMEOUT = 3600 -# Tools that require user interaction before execution +# Claude CLI built-ins that require user interaction before execution. +# Defined here (not in the claude backend) so the neutral registry and +# tests can reference the set without importing backend modules. INTERACTIVE_TOOLS = frozenset({ "AskUserQuestion", "ExitPlanMode", "EnterPlanMode", }) +# Approval kinds surfaced by sandboxed backends (Codex approval requests). +APPROVAL_KINDS = frozenset({ + "command_approval", + "file_approval", + "permission_approval", +}) + # Tools that modify files — trigger pre-execution snapshot FILE_MODIFY_TOOLS = frozenset({ "Edit", @@ -65,15 +69,33 @@ class PendingInteraction: deny_message: str = "" +@dataclass +class InteractionOutcome: + """How a pause resolved — the backend adapter maps this onto its + runtime's permission vocabulary.""" + + denied: bool = False + cancelled: bool = False # session stopped while waiting + message: str = "" + result: dict[str, Any] | None = None + + @property + def approved(self) -> bool: + return not self.denied and not self.cancelled + + class InteractiveToolHandler: - """Per-session handler that intercepts interactive tool calls. + """Per-session hub that pauses turns for user input. - Created for each session and registered with the SDK via can_use_tool. - The WebSocket server routes user answers to the correct handler via - the global registry. + Created for each session and registered in the global registry; the + WebSocket server routes user answers to the correct hub. Backend + adapters call :meth:`request_interaction` (interactive built-ins) or + :meth:`request_approval` (sandbox approval requests) and translate + the :class:`InteractionOutcome`. - Also captures file content snapshots before file-modifying tools execute, - enabling session-scoped diff computation. + Also tracks which files were already snapshotted this session so + permission adapters can capture pre-modification content exactly once + (see :meth:`mark_snapshotted`). """ def __init__( @@ -85,79 +107,77 @@ def __init__( ): """ Args: - session_id: The Nerve session this handler belongs to. + session_id: The Nerve session this hub belongs to. broadcast_fn: async fn(session_id, message_dict) — the broadcaster. - snapshot_fn: Optional async fn(session_id, file_path, content) — persists - original file content before modification for diff view. - interactive_capable: Whether the session channel supports interactive - tools (WebSocket UI). Non-interactive channels - (Telegram, cron) auto-deny to prevent deadlocks. + snapshot_fn: Optional async fn(session_id, file_path, content) — + persists original file content before modification. + interactive_capable: Whether the session channel supports + interactive pauses (WebSocket UI). + Non-interactive channels (Telegram, cron) + auto-deny to prevent deadlocks. """ self.session_id = session_id self._broadcast = broadcast_fn - self._snapshot_fn = snapshot_fn - self._interactive_capable = interactive_capable + self.snapshot_fn = snapshot_fn + self.interactive_capable = interactive_capable self._pending: dict[str, PendingInteraction] = {} - self._captured_files: set[str] = set() # paths already snapshotted this session + self._captured_files: set[str] = set() # paths snapshotted this session - async def can_use_tool( - self, - tool_name: str, - tool_input: dict[str, Any], - context: ToolPermissionContext, - ) -> PermissionResult: - """SDK permission callback. + # -- snapshot bookkeeping ------------------------------------------- # - Captures file snapshots before file-modifying tools, then - auto-approves non-interactive tools. - """ - # Capture pre-execution file snapshot for diff tracking - # (Also done via PreToolUse hook in engine.py as primary mechanism) - if self._snapshot_fn and tool_name in FILE_MODIFY_TOOLS: - file_path = tool_input.get("file_path") or tool_input.get("notebook_path") - if file_path and file_path not in self._captured_files: - self._captured_files.add(file_path) - content = _read_file_safe(file_path) - try: - await self._snapshot_fn(self.session_id, file_path, content) - except Exception as e: - logger.warning("Failed to save file snapshot for %s: %s", file_path, e) - - if tool_name not in INTERACTIVE_TOOLS: - return PermissionResultAllow() - - # Non-interactive channels: deny immediately to prevent deadlocks - if not self._interactive_capable: - deny_messages = { - "AskUserQuestion": ( - "AskUserQuestion is not available in this channel. " - "Use the Nerve `ask_user` tool to ask the user questions asynchronously." - ), - "EnterPlanMode": ( - "Plan mode is not available in non-web sessions. " - "Proceed with implementation directly." - ), - "ExitPlanMode": ( - "Plan mode is not available in non-web sessions." - ), - } + def mark_snapshotted(self, file_path: str) -> bool: + """Record *file_path* as snapshotted; True when newly recorded.""" + if file_path in self._captured_files: + return False + self._captured_files.add(file_path) + return True + + # -- pause requests -------------------------------------------------- # + + async def request_interaction( + self, tool_name: str, tool_input: dict[str, Any], + ) -> InteractionOutcome: + """Pause for an interactive built-in (question / plan approval).""" + if not self.interactive_capable: logger.info( "Session %s: auto-denying %s (non-interactive channel)", self.session_id, tool_name, ) - return PermissionResultDeny( - message=deny_messages.get( - tool_name, - f"{tool_name} is not available in this channel.", - ) + return InteractionOutcome( + denied=True, + message=f"{tool_name} is not available in this channel.", ) + return await self._pause( + tool_name, tool_input, _interaction_type(tool_name), + ) - return await self._handle_interactive(tool_name, tool_input) + async def request_approval( + self, kind: str, payload: dict[str, Any], + ) -> InteractionOutcome: + """Pause for a backend approval request (command / file change). - async def _handle_interactive( - self, tool_name: str, tool_input: dict[str, Any], - ) -> PermissionResult: - """Pause execution, broadcast to UI, wait for user response.""" + ``kind`` must be one of :data:`APPROVAL_KINDS`; ``payload`` is the + backend's request context (command, cwd, file changes, reason...) + rendered by the UI's approval card. + """ + if not self.interactive_capable: + logger.info( + "Session %s: auto-declining %s (non-interactive channel)", + self.session_id, kind, + ) + return InteractionOutcome( + denied=True, + message="Approval requests are not available in this channel.", + ) + return await self._pause(kind, payload, kind) + + async def _pause( + self, + tool_name: str, + tool_input: dict[str, Any], + interaction_type: str, + ) -> InteractionOutcome: + """Broadcast to the UI, wait for the user's response.""" interaction_id = str(uuid4()) pending = PendingInteraction( interaction_id=interaction_id, @@ -171,7 +191,7 @@ async def _handle_interactive( "type": "interaction", "session_id": self.session_id, "interaction_id": interaction_id, - "interaction_type": _interaction_type(tool_name), + "interaction_type": interaction_type, "tool_name": tool_name, "tool_input": tool_input, }) @@ -192,27 +212,33 @@ async def _handle_interactive( "Session %s: interaction %s timed out after %ds", self.session_id, interaction_id[:8], INTERACTION_TIMEOUT, ) - return PermissionResultDeny( - message=f"No response received after {_humanize_seconds(INTERACTION_TIMEOUT)} — timed out.", + return InteractionOutcome( + denied=True, + message=( + f"No response received after " + f"{_humanize_seconds(INTERACTION_TIMEOUT)} — timed out." + ), ) except asyncio.CancelledError: - logger.info("Session %s: interaction %s cancelled", self.session_id, interaction_id[:8]) - return PermissionResultDeny( + logger.info( + "Session %s: interaction %s cancelled", + self.session_id, interaction_id[:8], + ) + return InteractionOutcome( + denied=True, cancelled=True, message="Session stopped by user.", - interrupt=True, ) if pending.denied: - logger.info("Session %s: %s denied by user", self.session_id, tool_name) - return PermissionResultDeny(message=pending.deny_message or "Declined by user.") - - # For AskUserQuestion: inject answers into the tool input - if tool_name == "AskUserQuestion" and pending.result: - updated = {**tool_input, "answers": pending.result} - return PermissionResultAllow(updated_input=updated) + logger.info( + "Session %s: %s denied by user", self.session_id, tool_name, + ) + return InteractionOutcome( + denied=True, + message=pending.deny_message or "Declined by user.", + ) - # For ExitPlanMode/EnterPlanMode: just allow - return PermissionResultAllow() + return InteractionOutcome(result=pending.result) finally: # Always drop the pending entry and refresh the waiting indicator, # regardless of how the wait resolved (answered, denied, timeout, diff --git a/nerve/agent/prompts.py b/nerve/agent/prompts.py index c28f9ef..c47a807 100644 --- a/nerve/agent/prompts.py +++ b/nerve/agent/prompts.py @@ -34,21 +34,29 @@ def set_skill_manager(manager: Any) -> None: _skill_manager = manager -def _format_tool_list() -> str: +def _format_tool_list(excluded_tools: "set[str] | None" = None) -> str: """Generate tool list for system prompt from the default registry. - Tool names are prefixed with ``mcp__nerve__`` because that's how the - Claude Agent SDK exposes them: the in-process MCP server is named - "nerve", and the CLI namespaces every MCP tool as ``mcp____``. - Calling the bare ``spec.name`` fails with "No such tool available". + Tool names are prefixed with ``mcp__nerve__`` because that's how both + runtimes expose them: the MCP server is named "nerve", and MCP tools + are namespaced as ``mcp____`` (Claude CLI and Codex use + the same convention). Calling the bare ``spec.name`` fails. HoA tools are excluded — they don't usefully appear in the prompt when houseofagents is disabled, and including them when enabled bloats the prompt with rarely-used surface. The model still discovers them via the MCP tools/list call on first turn. + + ``excluded_tools`` mirrors the active backend's tool exclusions so + the prompt never advertises a tool the session's MCP server doesn't + serve (e.g. ``schedule_wakeup`` on the Claude backend, which has the + ScheduleWakeup built-in instead). """ + excluded = excluded_tools or set() lines = [] for spec in _get_prompt_tool_registry().list(include_hoa=False): + if spec.name in excluded: + continue # Take the first sentence of the description as the summary desc = spec.description.split("\n")[0].rstrip(".") lines.append(f"- `mcp__nerve__{spec.name}` — {desc}") @@ -102,6 +110,7 @@ def build_system_prompt( recalled_memories: list[str] | None = None, timezone_name: str = "America/New_York", skill_summaries: list[dict] | None = None, + excluded_tools: "set[str] | None" = None, ) -> str: """Build the full system prompt for the agent. @@ -145,7 +154,7 @@ def build_system_prompt( - **Workspace:** {workspace} You have access to the following custom tools: -{_format_tool_list()}""" +{_format_tool_list(excluded_tools)}""" parts.append(context) # Skills summary (progressive disclosure level 1: name + description only) diff --git a/nerve/agent/tools/claude_sdk_adapter.py b/nerve/agent/tools/claude_sdk_adapter.py index 5974026..9623a86 100644 --- a/nerve/agent/tools/claude_sdk_adapter.py +++ b/nerve/agent/tools/claude_sdk_adapter.py @@ -107,6 +107,7 @@ def build_session_mcp_server( ctx: ToolContext, *, include_hoa: bool = False, + exclude: "set[str] | None" = None, ) -> dict: """Build the per-session in-process MCP server. @@ -114,13 +115,22 @@ def build_session_mcp_server( ask_user/react/etc. always reference the correct session — no shared global, no race under concurrent sessions. + ``exclude`` drops tools by name — used by agent backends to hide + tools that duplicate a runtime built-in (e.g. the Claude backend + excludes ``schedule_wakeup``; the CLI's ScheduleWakeup covers it). + The returned dict matches the SDK's ``McpSdkServerConfig`` shape; ``alwaysLoad`` is set to ``True`` so the Claude Code CLI skips tool- search deferral on first turn (requires CLI >= 2.1.121; silently ignored on older versions). The nerve MCP's tools are used on almost every turn — deferring them adds a ToolSearch round-trip for no benefit. """ - sdk_tools = [_wrap_for_sdk(spec, ctx) for spec in registry.list(include_hoa=include_hoa)] + excluded = exclude or set() + sdk_tools = [ + _wrap_for_sdk(spec, ctx) + for spec in registry.list(include_hoa=include_hoa) + if spec.name not in excluded + ] config = create_sdk_mcp_server(name="nerve", version="1.0.0", tools=sdk_tools) config["alwaysLoad"] = True # type: ignore[typeddict-unknown-key] return config diff --git a/nerve/agent/tools/handlers/__init__.py b/nerve/agent/tools/handlers/__init__.py index c359e92..2e817e3 100644 --- a/nerve/agent/tools/handlers/__init__.py +++ b/nerve/agent/tools/handlers/__init__.py @@ -17,6 +17,7 @@ from nerve.agent.tools.handlers.skills import SKILL_SPECS from nerve.agent.tools.handlers.sources import SOURCE_SPECS from nerve.agent.tools.handlers.tasks import TASK_SPECS +from nerve.agent.tools.handlers.wakeups import WAKEUP_SPECS def build_default_registry() -> ToolRegistry: @@ -36,6 +37,7 @@ def build_default_registry() -> ToolRegistry: *SKILL_SPECS, *NOTIFICATION_SPECS, *MCP_ADMIN_SPECS, + *WAKEUP_SPECS, *HOA_SPECS, ): registry.register(spec) diff --git a/nerve/agent/tools/handlers/wakeups.py b/nerve/agent/tools/handlers/wakeups.py new file mode 100644 index 0000000..10569f4 --- /dev/null +++ b/nerve/agent/tools/handlers/wakeups.py @@ -0,0 +1,98 @@ +"""``schedule_wakeup`` — registry equivalent of the Claude CLI built-in. + +The Claude backend gets ScheduleWakeup as a CLI built-in tool (recorded +via a PostToolUse hook); backends without built-ins (Codex) expose this +registry tool instead, which persists the same wakeup rows the +cron-service sweep fires via ``engine.run(..., source="wakeup")``. + +The Claude backend EXCLUDES this tool from its per-session MCP server +(``ClaudeBackend.excluded_tools``) so the two paths never coexist in one +session. Satellite sessions (external Codex/Claude Code clients over the +HTTP MCP endpoint) are rejected in the handler: the wakeup sweep runs +sessions through the engine, which makes no sense for a session the +engine has never owned. +""" + +from __future__ import annotations + +import logging + +from nerve.agent.tools.registry import ToolContext, ToolResult, ToolSpec + +logger = logging.getLogger(__name__) + +SCHEDULE_WAKEUP_SCHEMA = { + "type": "object", + "properties": { + "delaySeconds": { + "type": "number", + "description": ( + "Seconds from now to wake this session up. " + "Clamped to [60, 3600]." + ), + }, + "prompt": { + "type": "string", + "description": ( + "The prompt to re-inject into this session when the wakeup " + "fires. Describe what future-you should check or continue." + ), + }, + "reason": { + "type": "string", + "description": ( + "One short sentence explaining the chosen delay " + "(shown to the user)." + ), + "default": "", + }, + }, + "required": ["delaySeconds", "prompt"], +} + + +async def schedule_wakeup_handler(ctx: ToolContext, args: dict) -> ToolResult: + if ctx.db is None or ctx.engine is None: + return ToolResult.text( + "schedule_wakeup is unavailable: engine not wired", is_error=True, + ) + + # Wakeups fire through engine.run() on this exact session — reject + # sessions the engine doesn't own (external MCP satellites). + try: + session = await ctx.db.get_session(ctx.session_id) + except Exception: + session = None + if session and session.get("source") == "external": + return ToolResult.text( + "schedule_wakeup is not available for external client sessions — " + "it can only wake sessions owned by the Nerve engine.", + is_error=True, + ) + + wakeup_id = await ctx.engine._record_wakeup(ctx.db, ctx.session_id, args) + if wakeup_id is None: + return ToolResult.text( + "No wakeup scheduled: a non-empty `prompt` is required.", + is_error=True, + ) + fire_at = ctx.engine._wakeup_fire_at(args.get("delaySeconds")) + return ToolResult.text( + f"Wakeup #{wakeup_id} scheduled (~{fire_at} UTC). This session will " + "be re-invoked with your prompt then. Omit further scheduling to " + "stop the loop." + ) + + +WAKEUP_SPECS = [ + ToolSpec( + name="schedule_wakeup", + description=( + "Schedule this session to wake up again after a delay (60–3600s) " + "with a prompt for your future self. Use for monitoring loops and " + "deferred follow-ups. The wakeup re-invokes THIS session." + ), + input_schema=SCHEDULE_WAKEUP_SCHEMA, + handler=schedule_wakeup_handler, + ), +] diff --git a/nerve/config.py b/nerve/config.py index 3198690..ba1e469 100644 --- a/nerve/config.py +++ b/nerve/config.py @@ -131,6 +131,15 @@ def from_dict(cls, d: dict) -> PromptRewriteConfig: @dataclass class AgentConfig: + # Agent backend for NEW sessions: "claude" (Claude Agent SDK) or + # "codex" (OpenAI Codex app-server; see CodexConfig). Existing + # sessions are sticky — the backend they were created with is stored + # in sessions.backend and always wins over this setting. + backend: str = "claude" + # Backend for NEW cron/hook sessions; empty → same as `backend`. + # Wakeup turns fire on existing sessions and inherit their stored + # backend — this only affects freshly minted cron/hook sessions. + cron_backend: str = "" model: str = "claude-opus-4-8" cron_model: str = "claude-sonnet-4-6" title_model: str = "claude-haiku-4-5-20251001" # Session title generation @@ -180,9 +189,16 @@ class AgentConfig: background_agent_permissions: bool = True prompt_rewrite: PromptRewriteConfig = field(default_factory=PromptRewriteConfig) + @property + def resolved_cron_backend(self) -> str: + """Backend used for new cron/hook sessions.""" + return self.cron_backend or self.backend + @classmethod def from_dict(cls, d: dict) -> AgentConfig: return cls( + backend=str(d.get("backend", "claude")).strip().lower(), + cron_backend=str(d.get("cron_backend") or "").strip().lower(), model=d.get("model", "claude-opus-4-8"), cron_model=d.get("cron_model", "claude-sonnet-4-6"), title_model=d.get("title_model", "claude-haiku-4-5-20251001"), @@ -892,6 +908,108 @@ def from_dict(cls, d: dict) -> ExternalAgentsConfig: ) +_CODEX_APPROVAL_POLICIES = ("never", "on-request", "untrusted") +_CODEX_SANDBOX_MODES = ("read-only", "workspace-write", "danger-full-access") + +# $/1M tokens; cached input bills at the discounted rate. Config values +# under codex.pricing REPLACE entries per model key (dict deep-merge). +_DEFAULT_CODEX_PRICING: dict[str, dict[str, float]] = { + "gpt-5.6-codex": {"input": 5.0, "cached_input": 0.5, "output": 30.0}, + "gpt-5.6-sol": {"input": 5.0, "cached_input": 0.5, "output": 30.0}, + "gpt-5.6-terra": {"input": 2.5, "cached_input": 0.25, "output": 15.0}, + "gpt-5.6-luna": {"input": 1.0, "cached_input": 0.1, "output": 6.0}, +} + + +@dataclass +class CodexConfig: + """OpenAI Codex backend (``codex app-server``) settings. + + Active only when ``agent.backend`` / ``agent.cron_backend`` is + "codex" (or a session was created on it). See + docs/plans/codex-backend.md. + """ + + bin_path: str = "codex" # PATH-resolved codex binary + home_dir: str = "~/.nerve/codex" # isolated CODEX_HOME (auth/config/sessions) + model: str = "gpt-5.6-codex" + cron_model: str = "" # empty → model + auth: str = "chatgpt" # chatgpt | api_key + api_key: str = "" # literal key (config.local.yaml) + api_key_env: str = "OPENAI_API_KEY" # env fallback when auth=api_key + sandbox: str = "danger-full-access" # read-only | workspace-write | danger-full-access + approval_policy: str = "never" # never | on-request | untrusted + # nerve effort vocabulary -> codex reasoning effort string + effort_map: dict[str, str] = field(default_factory=lambda: { + "max": "xhigh", "xhigh": "xhigh", "high": "high", + "medium": "medium", "low": "low", + }) + web_search: bool = True + tool_timeout_sec: int = 3600 # nerve MCP calls may block on ask_user + # Per-notification hang detection; 0/empty → agent.cli_idle_timeout_seconds + turn_idle_timeout_seconds: int = 0 + pricing: dict[str, dict[str, float]] = field( + default_factory=lambda: {k: dict(v) for k, v in _DEFAULT_CODEX_PRICING.items()}, + ) + # Arbitrary codex config-override passthrough (-c key=value at spawn) + extra_config: dict[str, Any] = field(default_factory=dict) + + @classmethod + def from_dict(cls, d: dict) -> "CodexConfig": + pricing = {k: dict(v) for k, v in _DEFAULT_CODEX_PRICING.items()} + raw_pricing = d.get("pricing") or {} + if isinstance(raw_pricing, dict): + for model_key, prices in raw_pricing.items(): + if isinstance(prices, dict): + pricing[str(model_key)] = { + str(k): float(v) for k, v in prices.items() + } + effort_map = { + "max": "xhigh", "xhigh": "xhigh", "high": "high", + "medium": "medium", "low": "low", + } + raw_effort = d.get("effort_map") or {} + if isinstance(raw_effort, dict): + effort_map.update({str(k): str(v) for k, v in raw_effort.items()}) + return cls( + bin_path=str(d.get("bin_path", "codex")), + home_dir=str(d.get("home_dir", "~/.nerve/codex")), + model=str(d.get("model", "gpt-5.6-codex")), + cron_model=str(d.get("cron_model") or ""), + auth=str(d.get("auth", "chatgpt")).strip().lower(), + api_key=str(d.get("api_key") or ""), + api_key_env=str(d.get("api_key_env", "OPENAI_API_KEY")), + sandbox=str(d.get("sandbox", "danger-full-access")), + approval_policy=str(d.get("approval_policy", "never")), + effort_map=effort_map, + web_search=bool(d.get("web_search", True)), + tool_timeout_sec=int(d.get("tool_timeout_sec", 3600)), + turn_idle_timeout_seconds=int(d.get("turn_idle_timeout_seconds", 0)), + pricing=pricing, + extra_config=dict(d.get("extra_config") or {}), + ) + + def validate(self) -> list[str]: + """Config-load-time validation; returns human-readable problems.""" + problems: list[str] = [] + if self.auth not in ("chatgpt", "api_key"): + problems.append( + f"codex.auth must be 'chatgpt' or 'api_key', got {self.auth!r}" + ) + if self.approval_policy not in _CODEX_APPROVAL_POLICIES: + problems.append( + f"codex.approval_policy must be one of " + f"{_CODEX_APPROVAL_POLICIES}, got {self.approval_policy!r} " + "(note: 'on-failure' is not accepted by the app-server v2 API)" + ) + if self.sandbox not in _CODEX_SANDBOX_MODES: + problems.append( + f"codex.sandbox must be one of {_CODEX_SANDBOX_MODES}, " + f"got {self.sandbox!r}" + ) + return problems + + @dataclass class McpServerConfig: """External MCP server configuration. @@ -1143,6 +1261,7 @@ class NerveConfig: docker: DockerConfig = field(default_factory=DockerConfig) proxy: ProxyConfig = field(default_factory=ProxyConfig) ollama: OllamaConfig = field(default_factory=OllamaConfig) + codex: CodexConfig = field(default_factory=CodexConfig) houseofagents: HouseOfAgentsConfig = field(default_factory=HouseOfAgentsConfig) langfuse: LangfuseConfig = field(default_factory=LangfuseConfig) xmemory: XmemoryConfig = field(default_factory=XmemoryConfig) @@ -1246,8 +1365,60 @@ def create_async_anthropic_client(self, timeout: float = 60.0) -> Any: timeout=timeout, ) + _KNOWN_BACKENDS = ("claude", "codex") + + def _validate_backend_config(self) -> None: + """Fail fast on unusable backend settings (called from from_dict). + + Unknown backend names are hard errors — a typo here would + otherwise surface as a confusing per-session failure. Codex + sub-config problems are hard errors only when a codex backend is + actually selected; otherwise the section is inert. + """ + for label, name in ( + ("agent.backend", self.agent.backend), + ("agent.cron_backend", self.agent.resolved_cron_backend), + ): + if name not in self._KNOWN_BACKENDS: + raise ValueError( + f"{label} must be one of {self._KNOWN_BACKENDS}, got {name!r}" + ) + codex_selected = "codex" in ( + self.agent.backend, self.agent.resolved_cron_backend, + ) + problems = self.codex.validate() + if problems: + if codex_selected: + raise ValueError("; ".join(problems)) + for p in problems: + logger.warning("Inactive codex config problem: %s", p) + if codex_selected: + if self.codex.model not in { + k for k in self.codex.pricing + } and not any( + key.lower() in self.codex.model.lower() + for key in self.codex.pricing + ): + logger.warning( + "codex.model %r has no codex.pricing entry — turn costs " + "will be recorded as unknown (tokens still tracked)", + self.codex.model, + ) + if self.ollama.enabled: + logger.warning( + "agent.backend=codex with ollama.enabled: Ollama models " + "cannot be served by the codex backend; sessions " + "explicitly selecting an Ollama model will fail", + ) + @classmethod def from_dict(cls, d: dict) -> NerveConfig: + config = cls._build_from_dict(d) + config._validate_backend_config() + return config + + @classmethod + def _build_from_dict(cls, d: dict) -> NerveConfig: return cls( workspace=_expand_path(d.get("workspace", "~/nerve-workspace")) or Path("~/nerve-workspace"), timezone=d.get("timezone", "America/New_York"), @@ -1270,6 +1441,7 @@ def from_dict(cls, d: dict) -> NerveConfig: docker=DockerConfig.from_dict(d.get("docker", {})), proxy=ProxyConfig.from_dict(d.get("proxy", {})), ollama=OllamaConfig.from_dict(d.get("ollama", {})), + codex=CodexConfig.from_dict(d.get("codex", {})), houseofagents=HouseOfAgentsConfig.from_dict(d.get("houseofagents", {})), langfuse=LangfuseConfig.from_dict(d.get("langfuse", {})), xmemory=XmemoryConfig.from_dict(d.get("xmemory", {})), diff --git a/nerve/db/migrations/v038_session_backend.py b/nerve/db/migrations/v038_session_backend.py new file mode 100644 index 0000000..f718ab2 --- /dev/null +++ b/nerve/db/migrations/v038_session_backend.py @@ -0,0 +1,26 @@ +"""V38: Track each session's agent backend. + +Multi-backend support (docs/plans/codex-backend.md): a session created +on one backend (claude / codex) must never be resumed on another — the +stored native session id (``sdk_session_id``) is meaningless across +runtimes. The engine stamps ``backend`` at first client build and the +sticky resolution rule makes the stored value always win over config, +so flipping ``agent.backend`` / ``agent.cron_backend`` never corrupts +existing conversations (including their scheduled wakeups). + +NULL means "created before this migration" and is read as "claude" — +every pre-existing session is a Claude session by definition. +""" + +from __future__ import annotations + +import logging + +import aiosqlite + +logger = logging.getLogger(__name__) + + +async def up(db: aiosqlite.Connection) -> None: + await db.execute("ALTER TABLE sessions ADD COLUMN backend TEXT") + logger.info("v038: added sessions.backend") diff --git a/nerve/db/sessions.py b/nerve/db/sessions.py index d951e6f..fa2f643 100644 --- a/nerve/db/sessions.py +++ b/nerve/db/sessions.py @@ -130,7 +130,7 @@ def _build_session_fields_update( "status", "sdk_session_id", "connected_at", "last_activity_at", "archived_at", "title", "message_count", "total_cost_usd", "parent_session_id", "forked_from_message", "last_memorized_at", - "starred", "model", + "starred", "model", "backend", } set_clauses: list[str] = [] params: list = [] diff --git a/nerve/gateway/auth.py b/nerve/gateway/auth.py index 06849b5..a6b2dcf 100644 --- a/nerve/gateway/auth.py +++ b/nerve/gateway/auth.py @@ -19,6 +19,11 @@ JWT_ALGORITHM = "HS256" JWT_EXPIRY_HOURS = 24 +# Audience claim on session-bound MCP tokens (see create_mcp_session_token). +MCP_AUDIENCE = "nerve-mcp" +# Claim carrying the bound nerve session id on MCP tokens. +MCP_SESSION_CLAIM = "nerve_session_id" + def verify_password(plain: str, hashed: str) -> bool: """Verify a plaintext password against a bcrypt hash.""" @@ -35,10 +40,41 @@ def create_token(jwt_secret: str) -> str: return jwt.encode(payload, jwt_secret, algorithm=JWT_ALGORITHM) -def decode_token(token: str, jwt_secret: str) -> dict: - """Decode and validate a JWT token.""" +def create_mcp_session_token(jwt_secret: str, session_id: str) -> str: + """Mint a session-bound MCP token for a backend-managed agent process. + + Carries ``aud=nerve-mcp`` + the bound session id so the external MCP + endpoint attributes every tool call to the real engine session + (instead of a satellite). Deliberately **no ``exp``**: the token + lives only in the spawned agent subprocess's environment, the bound + session under steady traffic is never idle-swept (a 24h expiry would + 401 mid-conversation), and revocation is the same as for every other + nerve token — rotate ``auth.jwt_secret``. + """ + payload = { + "iat": datetime.now(timezone.utc), + "sub": "backend-agent", + "aud": MCP_AUDIENCE, + MCP_SESSION_CLAIM: session_id, + } + return jwt.encode(payload, jwt_secret, algorithm=JWT_ALGORITHM) + + +def decode_token( + token: str, jwt_secret: str, audience: str | None = None, +) -> dict: + """Decode and validate a JWT token. + + ``audience=None`` (the default) accepts only aud-less tokens — PyJWT + rejects any token carrying an ``aud`` claim unless the caller + verifies it, so audience-scoped tokens (MCP session tokens) never + pass ordinary web-UI auth by accident. Callers that accept scoped + tokens pass the expected ``audience`` explicitly. + """ try: - return jwt.decode(token, jwt_secret, algorithms=[JWT_ALGORITHM]) + return jwt.decode( + token, jwt_secret, algorithms=[JWT_ALGORITHM], audience=audience, + ) except jwt.ExpiredSignatureError: raise HTTPException(status_code=401, detail="Token expired") except jwt.InvalidTokenError: diff --git a/nerve/mcp_server/auth.py b/nerve/mcp_server/auth.py index 705adc1..5053b88 100644 --- a/nerve/mcp_server/auth.py +++ b/nerve/mcp_server/auth.py @@ -22,7 +22,7 @@ from starlette.types import Scope from nerve.config import NerveConfig -from nerve.gateway.auth import decode_token +from nerve.gateway.auth import MCP_AUDIENCE, MCP_SESSION_CLAIM, decode_token logger = logging.getLogger(__name__) @@ -56,6 +56,31 @@ def _extract_token_from_scope(scope: Scope) -> str: return "" +def decode_mcp_token(token: str, jwt_secret: str) -> dict: + """Decode a token for the MCP endpoint. + + Two token shapes are accepted: + + * ordinary gateway tokens (no ``aud``) — external clients (Codex CLI, + Claude Code) that logged in via the web-UI flow; their tool calls + attribute to satellite sessions. + * session-bound tokens (``aud=nerve-mcp`` + ``nerve_session_id``) — + minted by :func:`nerve.gateway.auth.create_mcp_session_token` for + backend-managed agent subprocesses; their tool calls bind to the + real engine session. + + PyJWT rejects an ``aud``-carrying token unless the audience is + requested, and rejects an aud-less token when one is — hence the + two-step decode. + """ + try: + return decode_token(token, jwt_secret) + except HTTPException: + pass + # Not a plain token — try the MCP-audience shape (raises on failure). + return decode_token(token, jwt_secret, audience=MCP_AUDIENCE) + + def authenticate_mcp(scope: Scope, config: NerveConfig) -> dict | None: """Validate the JWT on an incoming MCP request. @@ -72,8 +97,22 @@ def authenticate_mcp(scope: Scope, config: NerveConfig) -> dict | None: raise McpAuthError("Missing token") try: - return decode_token(token, config.auth.jwt_secret) + return decode_mcp_token(token, config.auth.jwt_secret) except HTTPException as e: # decode_token raises FastAPI HTTPException; translate so the # caller doesn't need to import fastapi. raise McpAuthError(e.detail or "Invalid token") + + +def bound_session_id(payload: dict | None) -> str | None: + """The engine session a decoded MCP token is bound to (or ``None``). + + Only ``aud=nerve-mcp`` tokens carry the claim; ordinary tokens (and + dev mode's ``None`` payload) return ``None`` → satellite attribution. + """ + if not payload: + return None + if payload.get("aud") != MCP_AUDIENCE: + return None + session_id = payload.get(MCP_SESSION_CLAIM) + return str(session_id) if session_id else None diff --git a/nerve/mcp_server/http.py b/nerve/mcp_server/http.py index 52148cd..b5d8e0a 100644 --- a/nerve/mcp_server/http.py +++ b/nerve/mcp_server/http.py @@ -35,7 +35,12 @@ from nerve.agent.tools import ToolContext, ToolRegistry from nerve.mcp_server.audit import build_audit_writer -from nerve.mcp_server.auth import McpAuthError, authenticate_mcp +from nerve.mcp_server.auth import ( + McpAuthError, + authenticate_mcp, + bound_session_id, + decode_mcp_token, +) from nerve.mcp_server.server import build_mcp_server from nerve.mcp_server.session import SatelliteSessionResolver @@ -97,28 +102,73 @@ def _resolve_client_info() -> tuple[str | None, str | None, str | None]: return client_name, mcp_session_id, path +def _bound_session_from_request(config: "NerveConfig") -> str | None: + """Session id bound into the request's bearer token, if any. + + Backend-managed agent subprocesses (the Codex backend) authenticate + with a session-bound token (``aud=nerve-mcp`` + + ``nerve_session_id`` — see gateway.auth.create_mcp_session_token); + their tool calls must attribute to the REAL engine session so + notify/ask_user/memorize/task_* behave exactly like the in-process + Claude MCP. Ordinary tokens return ``None`` → satellite attribution. + + The signature was already verified at the ASGI mount; this re-decode + only extracts the (signed) claim — cheap HS256, per tool call. + """ + if not config.auth.jwt_secret: + return None + try: + rctx = request_ctx.get() + except LookupError: + return None + request = getattr(rctx, "request", None) + if request is None: + return None + token = "" + try: + auth_header = request.headers.get("authorization") or "" + if auth_header.lower().startswith("bearer "): + token = auth_header[7:].strip() + if not token: + token = request.query_params.get("token") or "" + except AttributeError: + return None + if not token: + return None + try: + payload = decode_mcp_token(token, config.auth.jwt_secret) + except Exception: + return None + return bound_session_id(payload) + + def build_ctx_resolver(engine: "AgentEngine", resolver: SatelliteSessionResolver): """Build the per-call_tool ``ToolContext`` resolver closure. The Server's ``call_tool`` handler invokes this for every tool call - to attribute the call to the correct satellite session. Per-call + to attribute the call to the correct session: a session-bound token + (backend-managed agents) binds directly to its engine session; + everything else goes through satellite attribution. Per-call resolution is cheap (the satellite session id is deterministic and the underlying ``get_session`` / ``create_session`` check is O(1) on the indexed primary key). """ async def _resolve() -> ToolContext: - client_name, mcp_session_id, _ = _resolve_client_info() + session_id = _bound_session_from_request(engine.config) - if mcp_session_id is None: - # Stateless requests / pre-initialize calls can land here. - # Fall back to a synthetic id so we still get a session row. - mcp_session_id = "stateless" + if session_id is None: + client_name, mcp_session_id, _ = _resolve_client_info() - session_id = await resolver.resolve( - client_name=client_name, - mcp_session_id=mcp_session_id, - ) + if mcp_session_id is None: + # Stateless requests / pre-initialize calls can land here. + # Fall back to a synthetic id so we still get a session row. + mcp_session_id = "stateless" + + session_id = await resolver.resolve( + client_name=client_name, + mcp_session_id=mcp_session_id, + ) return ToolContext( session_id=session_id, diff --git a/scripts/codex_smoke.py b/scripts/codex_smoke.py new file mode 100755 index 0000000..b53dd78 --- /dev/null +++ b/scripts/codex_smoke.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +"""Codex backend smoke test — run against the REAL codex app-server. + +Not part of CI (requires a codex binary + auth). Run before flipping any +config to the codex backend: + + CODEX_HOME=~/.nerve/codex codex login # once, if using chatgpt auth + .venv/bin/python scripts/codex_smoke.py [--model gpt-5.6-codex] + +Verifies, and prints results for docs/plans/codex-backend.md §17: + 1. spawn + initialize handshake + auth state + 2. thread/start → thread id + 3. one trivial turn → streamed text + usage + per-turn cost + 4. RSS of the app-server process (per-session memory footprint) + 5. fileChange item/started ordering probe: asks the model to edit a + scratch file and reports whether item/started fired BEFORE the file + content changed on disk (validates the pre-apply snapshot + assumption; if post-apply, the reverse-diff fallback is needed) +""" + +from __future__ import annotations + +import argparse +import asyncio +import os +import sys +import tempfile +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from nerve.agent.backends import BackendDeps, SessionSpec, TurnInput # noqa: E402 +from nerve.agent.backends import events as ev # noqa: E402 +from nerve.agent.backends.codex import CodexBackend # noqa: E402 +from nerve.config import NerveConfig # noqa: E402 + + +def _rss_mb(pid: int) -> float: + import subprocess + out = subprocess.run( + ["ps", "-o", "rss=", "-p", str(pid)], capture_output=True, text=True, + ).stdout.strip() + return int(out or 0) / 1024 + + +async def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--model", default=None) + parser.add_argument("--home", default=os.path.expanduser("~/.nerve/codex")) + parser.add_argument("--skip-filechange", action="store_true") + args = parser.parse_args() + + workspace = Path(tempfile.mkdtemp(prefix="codex-smoke-")) + cfg = NerveConfig.from_dict({ + "workspace": str(workspace), + "codex": { + "home_dir": args.home, + **({"model": args.model} if args.model else {}), + }, + }) + deps = BackendDeps( + config=cfg, db=None, registry=None, + tool_ctx_factory=lambda sid: None, + external_mcp_servers=lambda: [], + gateway_port=lambda: None, # no MCP bridge in the smoke + mint_session_token=None, + ) + backend = CodexBackend(deps) + + snapshots: list[tuple[str, str | None]] = [] + + async def snapshot(sid: str, path: str, content: str | None) -> None: + snapshots.append((path, content)) + + spec = SessionSpec( + session_id="smoke", source="web", model=args.model, effort="low", + system_prompt="You are a smoke test. Be terse.", + cwd=str(workspace), interactive=None, snapshot=snapshot, + idle_timeout=120.0, + ) + + print(f"→ spawning codex app-server (home={args.home})") + client = await backend.create_client(spec) + proc = client._transport._proc + print(f"✓ thread started: {client.native_session_id}") + print(f" app-server RSS after connect: {_rss_mb(proc.pid):.1f} MB") + + # --- trivial turn -------------------------------------------------- # + await client.start_turn(TurnInput(text="Reply with exactly: SMOKE-OK")) + text, done = "", None + async for event in client.receive_turn(): + if isinstance(event, ev.TextDelta): + text += event.text + elif isinstance(event, ev.TurnCompleted): + done = event + assert done is not None + print(f"✓ turn completed: status={done.status} model={done.model}") + print(f" text: {text.strip()[:80]!r}") + if done.usage: + print( + f" usage: in={done.usage.input_tokens} cached={done.usage.cache_read_tokens}" + f" out={done.usage.output_tokens} ctx_window={done.context_window}", + ) + print(f" cost: ${done.total_cost_usd}" if done.total_cost_usd is not None + else " cost: None (no pricing entry — check codex.pricing)") + print(f" app-server RSS after turn: {_rss_mb(proc.pid):.1f} MB") + + # --- fileChange ordering probe ------------------------------------- # + if not args.skip_filechange: + target = workspace / "probe.txt" + target.write_text("ORIGINAL\n") + pre_images: dict[str, str] = {} + + async def probing_snapshot(sid: str, path: str, content: str | None) -> None: + # capture what the DISK held at snapshot time + try: + pre_images[path] = Path(path).read_text() + except OSError: + pre_images[path] = "" + + client._spec.snapshot = probing_snapshot + await client.start_turn(TurnInput( + text=( + f"Edit the file {target} replacing ORIGINAL with CHANGED. " + "Do nothing else." + ), + )) + async for event in client.receive_turn(): + if isinstance(event, ev.TurnCompleted): + break + final = target.read_text() if target.exists() else "" + pre = pre_images.get(str(target), "") + print(f"✓ fileChange probe: final={final.strip()!r} snapshot-time={pre.strip()!r}") + if "ORIGINAL" in pre: + print(" → item/started fires PRE-APPLY: snapshots are valid ✅") + else: + print(" → item/started fired POST-APPLY: enable the reverse-diff " + "fallback (docs/plans/codex-backend.md §13) ⚠️") + + await client.disconnect() + print("✓ disconnect clean — smoke PASSED") + return 0 + + +if __name__ == "__main__": + raise SystemExit(asyncio.run(main())) diff --git a/tests/fixtures/codex_schema_meta.json b/tests/fixtures/codex_schema_meta.json new file mode 100644 index 0000000..19b2bfd --- /dev/null +++ b/tests/fixtures/codex_schema_meta.json @@ -0,0 +1,7 @@ +{ + "codex_cli_version": "0.144.1", + "generated_with": "codex app-server generate-json-schema", + "verified_date": "2026-07-10", + "v2_schema_sha256": "eb10053ceba382660c8c5023d2b74ce80776e1997c0a606b5153b467e186a30a", + "notes": "Shapes nerve/agent/backends/codex/ was verified against. Regenerate and diff when bumping the codex-cli min version." +} \ No newline at end of file diff --git a/tests/fixtures/fake_codex_appserver.py b/tests/fixtures/fake_codex_appserver.py new file mode 100755 index 0000000..465d650 --- /dev/null +++ b/tests/fixtures/fake_codex_appserver.py @@ -0,0 +1,278 @@ +#!/usr/bin/env python3 +"""Fake ``codex app-server`` for offline transport/backend tests. + +Speaks newline-delimited JSON-RPC 2.0 on stdio, mimicking the surface +nerve's CodexAppServerClient uses (schema shapes from codex-cli 0.144.1, +see tests/fixtures/codex_schema_meta.json). Behavior is selected via the +FAKE_CODEX_MODE env var: + + basic — one text turn: deltas → tokenUsage → turn/completed + tools — command + multi-file fileChange + mcp tool items + approval — emits a commandExecution approval REQUEST mid-turn and + KEEPS STREAMING deltas while the request is pending + (proves the client's reader never blocks on approvals — + the exact deadlock the official beta SDK has); completes + only after the client answers + resume_fail — thread/resume|fork answer with a JSON-RPC error; + thread/start succeeds (resume-miss recovery path) + interrupt — turn runs "forever" until turn/interrupt, then emits + turn/completed with status=interrupted + die_mid_turn — emits one delta then exits(1) mid-turn + failed_turn — emits an error notification then turn/completed(failed) + +The process also mirrors received config overrides (argv --config k=v) +back in the initialize response under _fake.configOverrides so tests can +assert what nerve sent (mcp_servers wiring etc.). +""" + +from __future__ import annotations + +import json +import os +import sys +import threading + +MODE = os.environ.get("FAKE_CODEX_MODE", "basic") + +_out_lock = threading.Lock() +_pending_approval_answer = threading.Event() +_approval_decision: dict = {} +_interrupted = threading.Event() +_active_turn: dict = {"threadId": None, "turnId": None} + + +def send(payload: dict) -> None: + with _out_lock: + sys.stdout.write(json.dumps(payload) + "\n") + sys.stdout.flush() + + +def notify(method: str, params: dict) -> None: + send({"jsonrpc": "2.0", "method": method, "params": params}) + + +def respond(req_id, result: dict) -> None: + send({"jsonrpc": "2.0", "id": req_id, "result": result}) + + +def respond_error(req_id, code: int, message: str) -> None: + send({"jsonrpc": "2.0", "id": req_id, "error": {"code": code, "message": message}}) + + +_next_server_id = [1000] + + +def server_request(method: str, params: dict) -> None: + _next_server_id[0] += 1 + send({ + "jsonrpc": "2.0", "id": _next_server_id[0], + "method": method, "params": params, + }) + + +def _config_overrides() -> list[str]: + out, args = [], sys.argv[1:] + for i, a in enumerate(args): + if a == "--config" and i + 1 < len(args): + out.append(args[i + 1]) + return out + + +def _usage(turn_id: str, thread_id: str) -> None: + notify("thread/tokenUsage/updated", { + "threadId": thread_id, "turnId": turn_id, + "tokenUsage": { + "last": { + "inputTokens": 1200, "cachedInputTokens": 1000, + "outputTokens": 50, "reasoningOutputTokens": 10, + "totalTokens": 1250, + }, + "total": { + "inputTokens": 1200, "cachedInputTokens": 1000, + "outputTokens": 50, "reasoningOutputTokens": 10, + "totalTokens": 1250, + }, + "modelContextWindow": 272000, + }, + }) + + +def _completed(turn_id: str, thread_id: str, status: str = "completed", + error: dict | None = None) -> None: + turn = { + "id": turn_id, "status": status, "items": [], + "durationMs": 1234, "startedAt": 1, "completedAt": 2, + } + if error: + turn["error"] = error + notify("turn/completed", {"threadId": thread_id, "turn": turn}) + + +def run_turn(thread_id: str, turn_id: str) -> None: + """Emit the scripted turn for the current MODE (worker thread).""" + if MODE in ("basic", "approval", "tools", "failed_turn"): + notify("turn/started", {"threadId": thread_id, + "turn": {"id": turn_id, "status": "inProgress", "items": []}}) + notify("item/reasoning/textDelta", + {"threadId": thread_id, "turnId": turn_id, "itemId": "r1", + "delta": "thinking..."}) + notify("item/agentMessage/delta", + {"threadId": thread_id, "turnId": turn_id, "itemId": "m1", + "delta": "Hello "}) + + if MODE == "approval": + # Approval request mid-turn; deltas keep flowing while pending. + server_request("item/commandExecution/requestApproval", { + "threadId": thread_id, "turnId": turn_id, + "itemId": "c1", "reason": "sandbox policy", + }) + # These MUST reach the client while the approval is unanswered. + for chunk in ("streaming ", "while ", "pending "): + notify("item/agentMessage/delta", + {"threadId": thread_id, "turnId": turn_id, "itemId": "m1", + "delta": chunk}) + _pending_approval_answer.wait(timeout=30) + notify("item/agentMessage/delta", + {"threadId": thread_id, "turnId": turn_id, "itemId": "m1", + "delta": f"decision={_approval_decision.get('decision')}"}) + + if MODE == "tools": + notify("item/started", { + "threadId": thread_id, "turnId": turn_id, + "item": {"id": "c1", "type": "commandExecution", + "command": ["echo", "hi"], "cwd": "/tmp", + "status": "inProgress"}, + }) + notify("item/commandExecution/outputDelta", { + "threadId": thread_id, "turnId": turn_id, "itemId": "c1", + "delta": "hi\n", + }) + notify("item/completed", { + "threadId": thread_id, "turnId": turn_id, + "item": {"id": "c1", "type": "commandExecution", + "command": ["echo", "hi"], "cwd": "/tmp", + "aggregatedOutput": "hi\n", "exitCode": 0, + "status": "completed"}, + }) + changes = [ + {"path": "/tmp/fake_a.txt", "kind": "update", "diff": "-a\n+b\n"}, + {"path": "/tmp/fake_b.txt", "kind": "add", "diff": "+new\n"}, + ] + notify("item/started", { + "threadId": thread_id, "turnId": turn_id, + "item": {"id": "f1", "type": "fileChange", "changes": changes, + "status": "inProgress"}, + }) + notify("item/completed", { + "threadId": thread_id, "turnId": turn_id, + "item": {"id": "f1", "type": "fileChange", "changes": changes, + "status": "completed"}, + }) + notify("item/started", { + "threadId": thread_id, "turnId": turn_id, + "item": {"id": "t1", "type": "mcpToolCall", "server": "nerve", + "tool": "memorize", "arguments": {"content": "x"}, + "status": "inProgress"}, + }) + notify("item/completed", { + "threadId": thread_id, "turnId": turn_id, + "item": {"id": "t1", "type": "mcpToolCall", "server": "nerve", + "tool": "memorize", "result": "Memorized: x", + "status": "completed"}, + }) + + if MODE == "die_mid_turn": + notify("turn/started", {"threadId": thread_id, + "turn": {"id": turn_id, "status": "inProgress", "items": []}}) + notify("item/agentMessage/delta", + {"threadId": thread_id, "turnId": turn_id, "itemId": "m1", + "delta": "about to die"}) + sys.stdout.flush() + os._exit(1) + + if MODE == "interrupt": + notify("turn/started", {"threadId": thread_id, + "turn": {"id": turn_id, "status": "inProgress", "items": []}}) + notify("item/agentMessage/delta", + {"threadId": thread_id, "turnId": turn_id, "itemId": "m1", + "delta": "working forever..."}) + _interrupted.wait(timeout=30) + _usage(turn_id, thread_id) + _completed(turn_id, thread_id, status="interrupted") + return + + if MODE == "failed_turn": + notify("error", { + "threadId": thread_id, "turnId": turn_id, + "error": {"message": "model exploded"}, "willRetry": False, + }) + _usage(turn_id, thread_id) + _completed(turn_id, thread_id, status="failed", + error={"message": "model exploded"}) + return + + _usage(turn_id, thread_id) + _completed(turn_id, thread_id) + + +def main() -> None: + threads_started = 0 + for line in sys.stdin: + line = line.strip() + if not line: + continue + try: + msg = json.loads(line) + except ValueError: + continue + + # Response to one of OUR server requests (approval answer). + if "method" not in msg and "id" in msg: + _approval_decision.update( + msg.get("result") if isinstance(msg.get("result"), dict) else {}, + ) + _pending_approval_answer.set() + continue + + method = msg.get("method") + req_id = msg.get("id") + + if method == "initialize": + respond(req_id, { + "userAgent": "fake-codex/0.144.1", + "_fake": {"configOverrides": _config_overrides(), + "env": {"CODEX_HOME": os.environ.get("CODEX_HOME", ""), + "NERVE_MCP_TOKEN_SET": bool(os.environ.get("NERVE_MCP_TOKEN"))}}, + }) + elif method == "initialized": + pass # notification, no response + elif method == "account/read": + respond(req_id, {"account": {"type": "chatgpt", "email": "fake@example.com"}}) + elif method == "account/login/start": + respond(req_id, {"loginId": "fake-login"}) + elif method == "thread/start": + threads_started += 1 + respond(req_id, {"thread": {"id": f"th_fake_{threads_started}"}}) + elif method in ("thread/resume", "thread/fork"): + if MODE == "resume_fail": + respond_error(req_id, -32600, "no rollout found for thread") + else: + respond(req_id, {"thread": {"id": msg["params"]["threadId"]}}) + elif method == "turn/start": + thread_id = msg["params"]["threadId"] + turn_id = f"turn_{threads_started}_1" + _active_turn.update({"threadId": thread_id, "turnId": turn_id}) + respond(req_id, {"turn": {"id": turn_id, "status": "inProgress", + "items": []}}) + threading.Thread( + target=run_turn, args=(thread_id, turn_id), daemon=True, + ).start() + elif method == "turn/interrupt": + respond(req_id, {}) + _interrupted.set() + else: + respond(req_id, {}) + + +if __name__ == "__main__": + main() diff --git a/tests/test_autonomous_turns.py b/tests/test_autonomous_turns.py index 4833ea6..96794ad 100644 --- a/tests/test_autonomous_turns.py +++ b/tests/test_autonomous_turns.py @@ -4,9 +4,10 @@ task_notification → full agent turn inside the subprocess). These tests cover the engine pieces that surface that activity: -- ``_handle_system_message`` — task lifecycle → background-task chips +- ``_handle_system_event`` — task lifecycle → background-task chips - ``_drain_pending_messages`` — buffered autonomous turns → UI + DB -- ``_sdk_buffer_used`` — non-destructive buffer probe + (consumed through the client's idle-event API) +- ``ClaudeClient.buffer_used`` — non-destructive buffer probe """ import asyncio @@ -19,6 +20,8 @@ from claude_agent_sdk import ResultMessage, SystemMessage +from nerve.agent.backends.base import SessionSpec +from nerve.agent.backends.claude import ClaudeClient, translate_message from nerve.agent.engine import AgentEngine, _TurnState @@ -69,8 +72,31 @@ def push(self, item: dict) -> None: self._send.send_nowait(item) -def _fake_client(stream: _FakeStream): - return SimpleNamespace(_query=SimpleNamespace(_message_receive=stream)) +def _fake_client(stream: _FakeStream) -> ClaudeClient: + """A real ClaudeClient wired to a fake SDK whose receive stream is the + given _FakeStream — the drain/watcher consume the client's idle-event + API (try_receive_idle_events / receive_idle_events / buffer_used / + is_alive) exactly as in production, fed by the same raw SDK payloads + as before the backend split.""" + client = ClaudeClient.__new__(ClaudeClient) + client._spec = SessionSpec( + session_id="s1", source="web", model="m", effort="high", + system_prompt="", cwd="/tmp", + ) + client._sdk = SimpleNamespace( + _query=SimpleNamespace(_message_receive=stream), + # A live subprocess (returncode None) so is_alive() reports True. + _transport=SimpleNamespace(_process=SimpleNamespace(returncode=None)), + ) + client._native_session_id = None + return client + + +async def _handle_system_message(engine: AgentEngine, session_id, message) -> None: + """Route an SDK SystemMessage the way the live pipeline does: translate + it into a normalized SystemEvent, then hand it to the engine.""" + for event in translate_message(message): + await engine._handle_system_event(session_id, event) def _sys_msg(subtype: str, **data) -> dict: @@ -104,25 +130,29 @@ def _result_msg() -> dict: # --------------------------------------------------------------------------- -# _sdk_buffer_used +# ClaudeClient.buffer_used # --------------------------------------------------------------------------- -def test_sdk_buffer_used_counts_pending(): +def test_buffer_used_counts_pending(): stream = _FakeStream([_sys_msg("task_updated", task_id="t1", patch={})]) client = _fake_client(stream) - assert AgentEngine._sdk_buffer_used(client) == 1 + assert client.buffer_used() == 1 stream.receive_nowait() - assert AgentEngine._sdk_buffer_used(client) == 0 + assert client.buffer_used() == 0 -def test_sdk_buffer_used_handles_missing_internals(): - assert AgentEngine._sdk_buffer_used(SimpleNamespace()) == 0 - assert AgentEngine._sdk_buffer_used(SimpleNamespace(_query=None)) == 0 +def test_buffer_used_handles_missing_internals(): + client = _fake_client(_FakeStream([])) + client._sdk = SimpleNamespace() + assert client.buffer_used() == 0 + client._sdk = SimpleNamespace(_query=None) + assert client.buffer_used() == 0 # --------------------------------------------------------------------------- -# _handle_system_message → background-task chips +# _handle_system_event → background-task chips (SystemMessage fixtures fed +# through translate_message, as in the live pipeline) # --------------------------------------------------------------------------- @@ -143,7 +173,7 @@ async def test_task_lifecycle_updates_registry_and_broadcasts(): description="run tests", task_type="local_bash", ), ) - await engine._handle_system_message("s1", started) + await _handle_system_message(engine, "s1", started) reg = engine._bg_task_registry["s1"] assert reg["t1"]["status"] == "running" @@ -156,7 +186,7 @@ async def test_task_lifecycle_updates_registry_and_broadcasts(): "task_notification", task_id="t1", status="completed", ), ) - await engine._handle_system_message("s1", notified) + await _handle_system_message(engine, "s1", notified) assert reg["t1"]["status"] == "done" assert len(events) == 2 @@ -173,7 +203,7 @@ async def test_task_failed_maps_to_failed_status(): subtype="task_notification", data=_sys_msg("task_notification", task_id="t9", status="failed"), ) - await engine._handle_system_message("s1", msg) + await _handle_system_message(engine, "s1", msg) assert engine._bg_task_registry["s1"]["t9"]["status"] == "failed" @@ -189,7 +219,7 @@ async def test_agent_task_type_uses_agent_tool(): description="explore repo", task_type="local_agent", ), ) - await engine._handle_system_message("s1", msg) + await _handle_system_message(engine, "s1", msg) assert engine._bg_task_registry["s1"]["t2"]["tool"] == "Agent" @@ -198,8 +228,8 @@ async def test_non_task_subtypes_ignored(): engine = _make_engine() with patch("nerve.agent.engine.broadcaster") as bc: bc.broadcast = AsyncMock() - await engine._handle_system_message( - "s1", SystemMessage(subtype="init", data={"type": "system"}), + await _handle_system_message( + engine, "s1", SystemMessage(subtype="init", data={"type": "system"}), ) bc.broadcast.assert_not_called() assert "s1" not in engine._bg_task_registry @@ -221,7 +251,7 @@ async def test_workflow_task_emits_progress_and_persists(): bc.broadcast_workflow_progress = AsyncMock( side_effect=lambda sid, tid, snap: wf_events.append((tid, snap)), ) - await engine._handle_system_message("s1", SystemMessage( + await _handle_system_message(engine, "s1", SystemMessage( subtype="task_started", data=_sys_msg( "task_started", task_id="wt", tool_use_id="wf-tool", @@ -229,7 +259,7 @@ async def test_workflow_task_emits_progress_and_persists(): description="tiny workflow", ), )) - await engine._handle_system_message("s1", SystemMessage( + await _handle_system_message(engine, "s1", SystemMessage( subtype="task_progress", data=_sys_msg( "task_progress", task_id="wt", tool_use_id="wf-tool", @@ -241,7 +271,7 @@ async def test_workflow_task_emits_progress_and_persists(): ], ), )) - await engine._handle_system_message("s1", SystemMessage( + await _handle_system_message(engine, "s1", SystemMessage( subtype="task_updated", data=_sys_msg( "task_updated", task_id="wt", tool_use_id="wf-tool", @@ -357,7 +387,7 @@ async def test_drain_processes_full_autonomous_turn(): assert {"type": "session_running", "session_id": "s1", "is_running": False} in events # Buffer fully consumed — nothing left to desync the next turn - assert AgentEngine._sdk_buffer_used(client) == 0 + assert client.buffer_used() == 0 @pytest.mark.asyncio @@ -398,7 +428,7 @@ async def test_drain_consumes_stray_result_without_turn(): assert turns == 0 assert finalized == [] - assert AgentEngine._sdk_buffer_used(client) == 0 + assert client.buffer_used() == 0 @pytest.mark.asyncio @@ -530,12 +560,13 @@ async def test_drain_multiple_turns_in_one_call(): # --------------------------------------------------------------------------- -# _process_sdk_message — ResultMessage handling via shared path +# _process_agent_event — ResultMessage (translated to TurnCompleted) via the +# shared path # --------------------------------------------------------------------------- @pytest.mark.asyncio -async def test_process_sdk_message_returns_true_on_result(): +async def test_process_agent_event_returns_true_on_result(): engine = _make_engine() st = _TurnState() result = ResultMessage( @@ -543,7 +574,9 @@ async def test_process_sdk_message_returns_true_on_result(): is_error=False, num_turns=1, session_id="sdk-9", total_cost_usd=0.5, usage={"input_tokens": 1}, ) - done = await engine._process_sdk_message("s1", result, st) + done = False + for event in translate_message(result): + done = await engine._process_agent_event("s1", event, st) assert done is True assert st.sdk_session_id == "sdk-9" assert st.last_usage == {"input_tokens": 1} @@ -648,7 +681,7 @@ async def test_idle_watcher_resumes_parked_session_on_background_completion(): "tool": "Bash", "status": "running"}, } assert engine._has_live_background_tasks("s1") is True - engine._is_client_dead = lambda _c: False + # (the fake client's subprocess reports alive — see _fake_client) engine.sessions = SimpleNamespace( get_client=lambda _sid: client, is_running=lambda _sid: False, diff --git a/tests/test_cache_policy.py b/tests/test_cache_policy.py index 9eeb7e2..45a3a96 100644 --- a/tests/test_cache_policy.py +++ b/tests/test_cache_policy.py @@ -7,13 +7,13 @@ import pytest +from nerve.agent.backends.claude import ClaudeBackend from nerve.agent.cache_policy import ( build_ttl_report, cache_ttl_env, estimate_live_ttl_delta, resolve_cache_ttl, ) -from nerve.agent.engine import AgentEngine from nerve.config import AgentConfig # --------------------------------------------------------------------------- @@ -162,12 +162,11 @@ def test_cadence_query_failure_falls_back_to_priors(self): # --------------------------------------------------------------------------- -# Engine env wiring — the switch reaches the CLI subprocess iff resolved 1h +# Backend env wiring — the switch reaches the CLI subprocess iff resolved 1h # --------------------------------------------------------------------------- -def _make_env_engine(is_bedrock: bool = False) -> AgentEngine: - engine = AgentEngine.__new__(AgentEngine) - engine.config = SimpleNamespace( +def _make_env_backend(is_bedrock: bool = False) -> ClaudeBackend: + config = SimpleNamespace( provider=SimpleNamespace( is_bedrock=is_bedrock, aws_region="", aws_profile="", aws_access_key_id="", aws_secret_access_key="", @@ -175,28 +174,28 @@ def _make_env_engine(is_bedrock: bool = False) -> AgentEngine: proxy=SimpleNamespace(enabled=False, host="", port=0), effective_api_key="", ) - return engine + return ClaudeBackend(SimpleNamespace(config=config)) def test_build_env_5m_has_no_cache_flag(): - env = _make_env_engine()._build_env(cache_ttl="5m") + env = _make_env_backend()._build_env(cache_ttl="5m") assert "ENABLE_PROMPT_CACHING_1H" not in env assert "FORCE_PROMPT_CACHING_5M" not in env # never force upstream off def test_build_env_1h_sets_cache_flag(): - env = _make_env_engine()._build_env(cache_ttl="1h") + env = _make_env_backend()._build_env(cache_ttl="1h") assert env["ENABLE_PROMPT_CACHING_1H"] == "1" def test_build_env_1h_bedrock_sets_bedrock_flag(): - env = _make_env_engine(is_bedrock=True)._build_env(cache_ttl="1h") + env = _make_env_backend(is_bedrock=True)._build_env(cache_ttl="1h") assert env["ENABLE_PROMPT_CACHING_1H"] == "1" assert env["ENABLE_PROMPT_CACHING_1H_BEDROCK"] == "1" def test_build_env_default_is_5m(): - env = _make_env_engine()._build_env() + env = _make_env_backend()._build_env() assert "ENABLE_PROMPT_CACHING_1H" not in env diff --git a/tests/test_codex_appserver.py b/tests/test_codex_appserver.py new file mode 100644 index 0000000..40349b3 --- /dev/null +++ b/tests/test_codex_appserver.py @@ -0,0 +1,381 @@ +"""CodexBackend / CodexAppServerClient integration tests. + +Driven against ``tests/fixtures/fake_codex_appserver.py`` — a scripted +stdio JSON-RPC subprocess mimicking codex-cli 0.144.1's app-server +surface. Everything runs offline. + +The crown-jewel test is ``test_approval_does_not_block_stream``: the +fake emits an approval REQUEST and then keeps streaming deltas that must +arrive while the approval is still pending. The official beta Python SDK +fails exactly this (its reader thread dispatches server requests +synchronously); nerve's asyncio client must not. +""" + +from __future__ import annotations + +import asyncio +import os +from pathlib import Path + +import pytest + +from nerve.agent.backends import BackendDeps, SessionSpec, TransportDiedError +from nerve.agent.backends import events as ev +from nerve.agent.backends.codex import CodexBackend +from nerve.agent.backends.base import TurnInput +from nerve.agent.interactive import InteractiveToolHandler +from nerve.config import NerveConfig + +FAKE_BIN = str(Path(__file__).parent / "fixtures" / "fake_codex_appserver.py") + + +def _config(tmp_path: Path, **codex_overrides) -> NerveConfig: + cfg = NerveConfig.from_dict({ + "workspace": str(tmp_path / "ws"), + "codex": { + "bin_path": FAKE_BIN, + "home_dir": str(tmp_path / "codex-home"), + "model": "gpt-5.6-codex", + **codex_overrides, + }, + }) + (tmp_path / "ws").mkdir(parents=True, exist_ok=True) + return cfg + + +def _deps(cfg: NerveConfig) -> BackendDeps: + return BackendDeps( + config=cfg, + db=None, + registry=None, + tool_ctx_factory=lambda sid: None, + external_mcp_servers=lambda: [], + gateway_port=lambda: 8900, + mint_session_token=lambda sid: f"tok-{sid}", + ) + + +class _Broadcasts: + def __init__(self): + self.messages: list[tuple[str, dict]] = [] + + async def __call__(self, session_id: str, message: dict) -> None: + self.messages.append((session_id, message)) + + +def _spec(cfg: NerveConfig, *, interactive: bool = True, **kw) -> SessionSpec: + hub = InteractiveToolHandler( + session_id=kw.get("session_id", "s1"), + broadcast_fn=_Broadcasts(), + interactive_capable=interactive, + ) + defaults = dict( + session_id="s1", source="web", model=None, effort="high", + system_prompt="You are Nerve.", cwd=str(cfg.workspace), + resume_native_id=None, fork=False, interactive=hub, + snapshot=None, record_wakeup=None, idle_timeout=15.0, + ) + defaults.update(kw) + return SessionSpec(**defaults) + + +async def _collect_turn(client) -> list: + events = [] + async for event in client.receive_turn(): + events.append(event) + return events + + +def _mode(monkeypatch, mode: str) -> None: + monkeypatch.setenv("FAKE_CODEX_MODE", mode) + + +@pytest.mark.asyncio +async def test_basic_turn_streams_and_completes(tmp_path, monkeypatch): + _mode(monkeypatch, "basic") + cfg = _config(tmp_path) + backend = CodexBackend(_deps(cfg)) + client = await backend.create_client(_spec(cfg)) + try: + assert client.native_session_id == "th_fake_1" # known at thread/start + await client.start_turn(TurnInput(text="hello")) + events = await _collect_turn(client) + + texts = [e.text for e in events if isinstance(e, ev.TextDelta)] + assert "".join(texts) == "Hello " + assert any(isinstance(e, ev.ThinkingDelta) for e in events) + assert isinstance(events[0], ev.ModelObserved) + assert events[0].model == "gpt-5.6-codex" + + done = events[-1] + assert isinstance(done, ev.TurnCompleted) + assert done.status == "completed" + assert done.native_session_id == "th_fake_1" + assert done.duration_ms == 1234 + assert done.context_window == 272000 + # OpenAI inputTokens INCLUDES cached — normalized split is disjoint. + assert done.usage.input_tokens == 200 + assert done.usage.cache_read_tokens == 1000 + assert done.usage.output_tokens == 50 + # cost from the default pricing table: + # 200*5 + 1000*0.5 + 50*30 = 1000+500+1500 = 3000 per 1M → $0.003 + assert done.total_cost_usd == pytest.approx(0.003) + # per-turn usage lands anthropic-shaped for the engine + shaped = done.usage.to_anthropic_shape() + assert shaped["input_tokens"] == 200 + assert shaped["cache_read_input_tokens"] == 1000 + assert shaped["cache_creation_input_tokens"] == 0 + finally: + await client.disconnect() + + +@pytest.mark.asyncio +async def test_config_overrides_carry_mcp_bridge(tmp_path, monkeypatch): + _mode(monkeypatch, "basic") + cfg = _config(tmp_path) + backend = CodexBackend(_deps(cfg)) + overrides = backend.build_config_overrides(_spec(cfg)) + joined = "\n".join(overrides) + assert 'mcp_servers.nerve.url="http://127.0.0.1:8900/mcp/v1"' in joined + assert 'mcp_servers.nerve.bearer_token_env_var="NERVE_MCP_TOKEN"' in joined + assert "project_doc_max_bytes=0" in joined + + client = await backend.create_client(_spec(cfg)) + try: + # The fake mirrors argv config overrides + env back at initialize; + # spawn env must carry the session token + isolated CODEX_HOME. + env = backend.build_env(_spec(cfg)) + assert env["NERVE_MCP_TOKEN"] == "tok-s1" + assert env["CODEX_HOME"] == str(tmp_path / "codex-home") + finally: + await client.disconnect() + + +@pytest.mark.asyncio +async def test_tools_map_to_claude_vocabulary(tmp_path, monkeypatch): + _mode(monkeypatch, "tools") + cfg = _config(tmp_path) + snapshots: list[tuple[str, str]] = [] + + async def snapshot(sid, path, content): + snapshots.append((sid, path)) + + backend = CodexBackend(_deps(cfg)) + client = await backend.create_client(_spec(cfg, snapshot=snapshot)) + try: + await client.start_turn(TurnInput(text="do things")) + events = await _collect_turn(client) + + uses = [e for e in events if isinstance(e, ev.ToolUse)] + results = [e for e in events if isinstance(e, ev.ToolResult)] + by_name = {u.name for u in uses} + assert "Bash" in by_name + assert "Edit" in by_name + assert "mcp__nerve__memorize" in by_name + + bash = next(u for u in uses if u.name == "Bash") + assert bash.input["command"] == "echo hi" + bash_result = next(r for r in results if r.tool_use_id == bash.tool_use_id) + assert "hi" in bash_result.content + assert bash_result.is_error is False + + # Multi-file fileChange fans out: one ToolUse/ToolResult per file. + edits = [u for u in uses if u.name == "Edit"] + assert {u.input["file_path"] for u in edits} == { + "/tmp/fake_a.txt", "/tmp/fake_b.txt", + } + edit_ids = {u.tool_use_id for u in edits} + edit_results = [r for r in results if r.tool_use_id in edit_ids] + assert len(edit_results) == 2 + assert any("-a" in (r.content or "") for r in edit_results) + + # Pre-apply snapshots captured for every changed path. + assert {p for _, p in snapshots} == {"/tmp/fake_a.txt", "/tmp/fake_b.txt"} + + mcp = next(u for u in uses if u.name == "mcp__nerve__memorize") + assert mcp.input == {"content": "x"} + finally: + await client.disconnect() + + +@pytest.mark.asyncio +async def test_approval_does_not_block_stream(tmp_path, monkeypatch): + """Deltas emitted while an approval is pending MUST reach the client + before the approval resolves — the reader can never block on user + input (the official SDK's deadlock).""" + _mode(monkeypatch, "approval") + cfg = _config(tmp_path, approval_policy="on-request") + + delta_seen_before_answer = asyncio.Event() + answered = asyncio.Event() + + class Hub(InteractiveToolHandler): + async def request_approval(self, kind, payload): + # Wait until interleaved deltas prove the stream is alive, + # then approve. + await asyncio.wait_for(delta_seen_before_answer.wait(), timeout=10) + answered.set() + from nerve.agent.interactive import InteractionOutcome + assert kind == "command_approval" + assert payload.get("itemId") == "c1" + return InteractionOutcome() # approved + + hub = Hub("s1", _Broadcasts(), interactive_capable=True) + backend = CodexBackend(_deps(cfg)) + client = await backend.create_client(_spec(cfg, interactive=hub)) + # replace the spec hub (SessionSpec is frozen into the client at build) + client._spec.interactive = hub + try: + await client.start_turn(TurnInput(text="run something")) + text = "" + async for event in client.receive_turn(): + if isinstance(event, ev.TextDelta): + text += event.text + if "pending" in text and not answered.is_set(): + delta_seen_before_answer.set() + if isinstance(event, ev.TurnCompleted): + break + assert "streaming while pending" in text + assert "decision=accept" in text + assert answered.is_set() + finally: + await client.disconnect() + + +@pytest.mark.asyncio +async def test_approval_declined_on_noninteractive_source(tmp_path, monkeypatch): + _mode(monkeypatch, "approval") + cfg = _config(tmp_path, approval_policy="on-request") + backend = CodexBackend(_deps(cfg)) + client = await backend.create_client(_spec(cfg, interactive=False)) + try: + await client.start_turn(TurnInput(text="run")) + events = await _collect_turn(client) + text = "".join(e.text for e in events if isinstance(e, ev.TextDelta)) + assert "decision=decline" in text # auto-declined, turn still completed + assert isinstance(events[-1], ev.TurnCompleted) + finally: + await client.disconnect() + + +@pytest.mark.asyncio +async def test_resume_miss_falls_back_to_fresh_thread(tmp_path, monkeypatch): + _mode(monkeypatch, "resume_fail") + cfg = _config(tmp_path) + backend = CodexBackend(_deps(cfg)) + client = await backend.create_client( + _spec(cfg, resume_native_id="th_stale_123"), + ) + try: + assert client.resume_dropped is True + assert client.native_session_id == "th_fake_1" # fresh thread + finally: + await client.disconnect() + + +@pytest.mark.asyncio +async def test_interrupt_terminates_receive_turn(tmp_path, monkeypatch): + _mode(monkeypatch, "interrupt") + cfg = _config(tmp_path) + backend = CodexBackend(_deps(cfg)) + client = await backend.create_client(_spec(cfg)) + try: + await client.start_turn(TurnInput(text="loop forever")) + + async def _interrupt_soon(): + await asyncio.sleep(0.2) + await client.interrupt() + + interrupter = asyncio.create_task(_interrupt_soon()) + events = await asyncio.wait_for(_collect_turn(client), timeout=10) + await interrupter + done = events[-1] + assert isinstance(done, ev.TurnCompleted) + assert done.status == "interrupted" # graceful /stop wait works + finally: + await client.disconnect() + + +@pytest.mark.asyncio +async def test_failed_turn_completes_with_error(tmp_path, monkeypatch): + _mode(monkeypatch, "failed_turn") + cfg = _config(tmp_path) + backend = CodexBackend(_deps(cfg)) + client = await backend.create_client(_spec(cfg)) + try: + await client.start_turn(TurnInput(text="explode")) + events = await _collect_turn(client) + done = events[-1] + assert isinstance(done, ev.TurnCompleted) + assert done.status == "failed" + assert "model exploded" in (done.error or "") + # non-retryable error surfaced as a system event too + assert any( + isinstance(e, ev.SystemEvent) and e.subtype == "codex_error" + for e in events + ) + finally: + await client.disconnect() + + +@pytest.mark.asyncio +async def test_transport_death_mid_turn_raises(tmp_path, monkeypatch): + _mode(monkeypatch, "die_mid_turn") + cfg = _config(tmp_path) + backend = CodexBackend(_deps(cfg)) + client = await backend.create_client(_spec(cfg)) + try: + await client.start_turn(TurnInput(text="die")) + with pytest.raises(TransportDiedError): + await asyncio.wait_for(_collect_turn(client), timeout=10) + assert client.is_alive() is False + finally: + await client.disconnect() + + +@pytest.mark.asyncio +async def test_image_inputs_convert_to_data_urls(tmp_path, monkeypatch): + _mode(monkeypatch, "basic") + cfg = _config(tmp_path) + backend = CodexBackend(_deps(cfg)) + client = await backend.create_client(_spec(cfg)) + try: + import base64 + png = base64.b64encode(b"\x89PNG\r\n\x1a\n" + b"0" * 16).decode() + items = client._build_input_items(TurnInput( + text="look", + images=[ + {"type": "base64", "media_type": "image/png", "data": png}, + {"path": "/tmp/pic.png"}, + {"type": "text_file", "filename": "notes.txt", "content": "hi"}, + {"type": "base64", "media_type": "application/pdf", "data": "aGk="}, + ], + )) + types = [i["type"] for i in items] + assert types[0] == "text" + assert "image" in types and "localImage" in types + text_item = items[0]["text"] + assert "look" in text_item + assert "Attached: notes.txt" in text_item # text file inlined + assert "PDF attachment could not be delivered" in text_item # explicit degradation + image = next(i for i in items if i["type"] == "image") + assert image["url"].startswith("data:image/png;base64,") + finally: + await client.disconnect() + + +@pytest.mark.asyncio +async def test_idle_stream_is_absent(tmp_path, monkeypatch): + _mode(monkeypatch, "basic") + cfg = _config(tmp_path) + backend = CodexBackend(_deps(cfg)) + assert backend.capabilities.supports_idle_stream is False + assert backend.capabilities.cost_is_cumulative is False + assert backend.capabilities.supports_cache_ttl is False + client = await backend.create_client(_spec(cfg)) + try: + assert client.try_receive_idle_events() is None + assert await client.receive_idle_events(0.1) is None + assert client.buffer_used() == 0 + finally: + await client.disconnect() diff --git a/tests/test_codex_protocol.py b/tests/test_codex_protocol.py new file mode 100644 index 0000000..68ee99b --- /dev/null +++ b/tests/test_codex_protocol.py @@ -0,0 +1,166 @@ +"""Codex notification→event mapping + pricing unit tests (no subprocess).""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from nerve.agent.backends import events as ev +from nerve.agent.backends.base import SessionSpec +from nerve.agent.backends.codex.backend import CodexBackend, CodexClient +from nerve.agent.backends.codex.pricing import compute_cost, match_pricing +from nerve.config import NerveConfig + + +def _client(tmp_path, **codex_overrides) -> CodexClient: + cfg = NerveConfig.from_dict({ + "workspace": str(tmp_path), + "codex": {"home_dir": str(tmp_path / "home"), **codex_overrides}, + }) + deps = SimpleNamespace( + config=cfg, + external_mcp_servers=lambda: [], + gateway_port=lambda: None, + mint_session_token=None, + tool_ctx_factory=lambda sid: None, + registry=None, + db=None, + ) + backend = CodexBackend(deps) + spec = SessionSpec( + session_id="s1", source="web", model=None, effort="high", + system_prompt="", cwd=str(tmp_path), + ) + return CodexClient(backend, spec) + + +@pytest.mark.asyncio +async def test_unknown_notifications_are_tolerated(tmp_path): + client = _client(tmp_path) + assert await client._map_notification("some/future/thing", {"x": 1}) == [] + assert await client._map_notification("", {}) == [] + + +@pytest.mark.asyncio +async def test_stale_turn_usage_is_scoped(tmp_path): + client = _client(tmp_path) + events = await client._map_notification("thread/tokenUsage/updated", { + "threadId": "t", "turnId": "turn_1", + "tokenUsage": { + "last": {"inputTokens": 10, "cachedInputTokens": 4, + "outputTokens": 2, "reasoningOutputTokens": 0, + "totalTokens": 12}, + "modelContextWindow": 400000, + }, + }) + assert events == [] # retained, not emitted + done = client._map_turn_completed({ + "turn": {"id": "turn_1", "status": "completed", "durationMs": 5}, + }) + assert done.usage.input_tokens == 6 # 10 - 4 cached (disjoint split) + assert done.usage.cache_read_tokens == 4 + assert done.context_window == 400000 + + +@pytest.mark.asyncio +async def test_model_rerouted_updates_serving_model(tmp_path): + client = _client(tmp_path) + events = await client._map_notification("model/rerouted", {"model": "gpt-5.6-terra"}) + assert events == [ev.ModelObserved(model="gpt-5.6-terra")] + done = client._map_turn_completed({"turn": {"id": "x", "status": "completed"}}) + assert done.model == "gpt-5.6-terra" + + +@pytest.mark.asyncio +async def test_command_exit_code_marks_error(tmp_path): + client = _client(tmp_path) + await client._map_notification("item/started", {"item": { + "id": "c9", "type": "commandExecution", "command": ["false"], + }}) + events = client._map_item_completed({ + "id": "c9", "type": "commandExecution", + "command": ["false"], "aggregatedOutput": "", "exitCode": 3, + }) + assert len(events) == 1 + assert events[0].is_error is True + assert "exit code 3" in events[0].content + + +def test_turn_status_fallbacks(tmp_path): + client = _client(tmp_path) + weird = client._map_turn_completed({"turn": {"id": "x", "status": "inProgress"}}) + assert weird.status == "completed" # defensive downgrade, logged + failed = client._map_turn_completed({ + "turn": {"id": "x", "status": "failed", "error": {"message": "boom"}}, + }) + assert failed.status == "failed" and failed.error == "boom" + + +def test_pricing_matches_longest_substring(): + table = { + "gpt-5.6": {"input": 1.0, "cached_input": 0.1, "output": 2.0}, + "gpt-5.6-sol": {"input": 5.0, "cached_input": 0.5, "output": 30.0}, + } + assert match_pricing("gpt-5.6-sol-20260709", table)["input"] == 5.0 + assert match_pricing("gpt-5.6-luna", table)["input"] == 1.0 + assert match_pricing("o5-mini", table) is None + assert match_pricing(None, table) is None + + +def test_cost_none_for_unknown_model_never_estimated(): + usage = ev.NormalizedUsage( + input_tokens=1_000_000, output_tokens=1_000_000, + cache_read_tokens=1_000_000, + ) + assert compute_cost("mystery-model", usage, {"gpt-5.6": { + "input": 1.0, "cached_input": 0.1, "output": 2.0, + }}) is None + assert compute_cost("gpt-5.6", None, {"gpt-5.6": {"input": 1.0}}) is None + got = compute_cost("gpt-5.6", usage, {"gpt-5.6": { + "input": 1.0, "cached_input": 0.1, "output": 2.0, + }}) + assert got == pytest.approx(1.0 + 0.1 + 2.0) + + +def test_normalized_usage_anthropic_shape_contract(): + # Codex-shaped usage synthesizes the canonical keys + keeps raw. + u = ev.NormalizedUsage( + input_tokens=6, output_tokens=2, cache_read_tokens=4, + cache_creation_tokens=0, raw={"last": {"inputTokens": 10}}, + ) + shaped = u.to_anthropic_shape() + assert shaped["input_tokens"] == 6 + assert shaped["cache_read_input_tokens"] == 4 + assert shaped["cache_creation_input_tokens"] == 0 + assert shaped["_raw"] == {"last": {"inputTokens": 10}} + + # Claude-shaped usage passes through byte-identical (cache-TTL split + # readers depend on nested cache_creation.ephemeral_* surviving). + native = { + "input_tokens": 100, "output_tokens": 5, + "cache_read_input_tokens": 7, "cache_creation_input_tokens": 3, + "cache_creation": {"ephemeral_5m_input_tokens": 3}, + "server_tool_use": {"web_search_requests": 1}, + } + u2 = ev.NormalizedUsage.from_anthropic(native) + assert u2.to_anthropic_shape() is native + assert u2.input_tokens == 100 and u2.cache_read_tokens == 7 + + +def test_effort_mapping_and_defaults(tmp_path): + client = _client(tmp_path, effort_map={"max": "xhigh", "low": "minimal"}) + backend = client._backend + assert backend.map_effort("max") == "xhigh" + assert backend.map_effort("low") == "minimal" + assert backend.map_effort("high") == "high" # default map preserved + assert backend.map_effort("unknown") is None # omitted from turn/start + + +def test_backend_notes_appended_to_developer_instructions(tmp_path): + client = _client(tmp_path) + params = client._backend.thread_params(client._spec) + assert params["developerInstructions"].startswith("") + assert "schedule_wakeup" in params["developerInstructions"] + assert params["approvalPolicy"] == "never" + assert params["sandbox"] == "danger-full-access" diff --git a/tests/test_engine.py b/tests/test_engine.py index f90f082..326f1d3 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -1,14 +1,16 @@ -"""Tests for nerve.agent.engine — pure helpers (no SDK state).""" +"""Tests for nerve.agent.engine and nerve.agent.backends.claude — pure +helpers (no SDK subprocess).""" import asyncio import os -from pathlib import Path from types import SimpleNamespace from unittest.mock import AsyncMock, patch import pytest -from claude_agent_sdk import AssistantMessage +from claude_agent_sdk import AssistantMessage, TextBlock +from nerve.agent.backends.base import SessionSpec +from nerve.agent.backends.claude import ClaudeBackend, ClaudeClient, translate_message from nerve.agent.engine import AgentEngine, _TurnState, _model_family from nerve.config import AgentConfig @@ -53,12 +55,12 @@ ], ) def test_effective_effort(value, model, expected): - assert AgentEngine._effective_effort(value, model) == expected + assert ClaudeBackend._effective_effort(value, model) == expected def test_effective_effort_model_default_none(): # Signature symmetry with _parse_thinking_config - assert AgentEngine._effective_effort("max") == "max" + assert ClaudeBackend._effective_effort("max") == "max" @pytest.mark.parametrize( @@ -82,15 +84,15 @@ def test_base_effort_for_source_then_capped(): # A cron turn at the default cron_effort stays "medium" after the model-cap # pass on Sonnet 4.6 (which tops out at "high", so medium is unaffected). base = AgentEngine._base_effort_for_source("cron", "max", "medium") - assert AgentEngine._effective_effort(base, "claude-sonnet-4-6") == "medium" + assert ClaudeBackend._effective_effort(base, "claude-sonnet-4-6") == "medium" # Sonnet 5 is not in the cap table, so cron_effort passes through unchanged. - assert AgentEngine._effective_effort(base, "claude-sonnet-5") == "medium" + assert ClaudeBackend._effective_effort(base, "claude-sonnet-5") == "medium" # An interactive turn keeps "max", which caps to "high" on Sonnet 4.6. base = AgentEngine._base_effort_for_source("web", "max", "medium") - assert AgentEngine._effective_effort(base, "claude-sonnet-4-6") == "high" + assert ClaudeBackend._effective_effort(base, "claude-sonnet-4-6") == "high" # A cron turn whose cron_effort is left at "max" still caps to the model max. base = AgentEngine._base_effort_for_source("cron", "max", "max") - assert AgentEngine._effective_effort(base, "claude-sonnet-4-6") == "high" + assert ClaudeBackend._effective_effort(base, "claude-sonnet-4-6") == "high" def test_agent_config_cron_effort_default_and_override(): @@ -103,7 +105,7 @@ def test_agent_config_cron_effort_default_and_override(): # --------------------------------------------------------------------------- -# _iter_response_with_timeout — hung-CLI detection +# ClaudeClient.receive_turn — per-message idle timeout (hung-CLI detection) # --------------------------------------------------------------------------- @@ -139,97 +141,129 @@ async def _gen(): return _gen() +def _turn_client(sdk: _StubClient, session_id: str, idle_timeout: float) -> ClaudeClient: + """A ClaudeClient wired to a stub SDK (no subprocess).""" + client = ClaudeClient.__new__(ClaudeClient) + client._sdk = sdk + client._spec = SessionSpec( + session_id=session_id, source="web", model="m", effort="high", + system_prompt="", cwd="/tmp", idle_timeout=idle_timeout, + ) + client._native_session_id = None + return client + + +def _text_msg(text: str) -> AssistantMessage: + return AssistantMessage(content=[TextBlock(text=text)], model="claude-test") + + +def _translated(messages: list) -> list: + """The normalized events the given SDK messages translate into.""" + return [event for m in messages for event in translate_message(m)] + + @pytest.mark.asyncio -async def test_iter_response_yields_messages_normally(): +async def test_receive_turn_yields_events_normally(): """Fast SDK stream completes without timing out.""" - client = _StubClient(["a", "b", "c"]) + messages = [_text_msg("a"), _text_msg("b"), _text_msg("c")] + sdk = _StubClient(messages) + client = _turn_client(sdk, "sess-1", idle_timeout=5.0) seen = [] - async for msg in AgentEngine._iter_response_with_timeout( - client, "sess-1", idle_timeout=5.0, - ): - seen.append(msg) - assert seen == ["a", "b", "c"] + async for event in client.receive_turn(): + seen.append(event) + assert seen == _translated(messages) # Generator was closed cleanly when it ran to completion. - assert client.aclose_calls == 1 + assert sdk.aclose_calls == 1 @pytest.mark.asyncio -async def test_iter_response_raises_on_idle_timeout(): +async def test_receive_turn_raises_on_idle_timeout(): """If the SDK goes silent past idle_timeout, raise TimeoutError.""" # Yields one message, then hangs long enough to trip a 50ms timeout. - client = _StubClient(["a"], hang=True, hang_seconds=2.0) + messages = [_text_msg("a")] + sdk = _StubClient(messages, hang=True, hang_seconds=2.0) + client = _turn_client(sdk, "sess-2", idle_timeout=0.05) seen = [] with pytest.raises(asyncio.TimeoutError): - async for msg in AgentEngine._iter_response_with_timeout( - client, "sess-2", idle_timeout=0.05, - ): - seen.append(msg) - # The first message arrived before the hang. - assert seen == ["a"] + async for event in client.receive_turn(): + seen.append(event) + # The first message's events arrived before the hang. + assert seen == _translated(messages) # The underlying iterator was closed before the exception propagated. - assert client.aclose_calls == 1 + assert sdk.aclose_calls == 1 @pytest.mark.asyncio -async def test_iter_response_disabled_when_timeout_zero(): +async def test_receive_turn_disabled_when_timeout_zero(): """idle_timeout <= 0 disables the timeout (legacy behaviour).""" # Hangs forever after 1 message. Without a timeout we'd wait forever; # to verify "disabled" we wrap the whole call in our own short outer # timeout and assert that's what fired (not the inner one). - client = _StubClient(["a"], hang=True, hang_seconds=10.0) + messages = [_text_msg("a")] + sdk = _StubClient(messages, hang=True, hang_seconds=10.0) + client = _turn_client(sdk, "sess-3", idle_timeout=0) seen = [] with pytest.raises(asyncio.TimeoutError): async with asyncio.timeout(0.1): - async for msg in AgentEngine._iter_response_with_timeout( - client, "sess-3", idle_timeout=0, - ): - seen.append(msg) - assert seen == ["a"] + async for event in client.receive_turn(): + seen.append(event) + assert seen == _translated(messages) # Outer-cancel still triggers the finally block → aclose() runs. - assert client.aclose_calls == 1 + assert sdk.aclose_calls == 1 @pytest.mark.asyncio -async def test_iter_response_handles_empty_stream(): +async def test_receive_turn_handles_empty_stream(): """Empty receive_response (e.g. CLI exits immediately) returns cleanly.""" - client = _StubClient([]) + sdk = _StubClient([]) + client = _turn_client(sdk, "sess-4", idle_timeout=5.0) seen = [] - async for msg in AgentEngine._iter_response_with_timeout( - client, "sess-4", idle_timeout=5.0, - ): - seen.append(msg) + async for event in client.receive_turn(): + seen.append(event) assert seen == [] - assert client.aclose_calls == 1 -# _sdk_resume_file_exists + assert sdk.aclose_calls == 1 + + +# --------------------------------------------------------------------------- +# ClaudeBackend.validate_resume_target # --------------------------------------------------------------------------- -def _make_engine(workspace: str = "/root/nerve-workspace") -> AgentEngine: - """Minimal AgentEngine stub (only config.workspace is needed).""" - engine = AgentEngine.__new__(AgentEngine) - engine.config = SimpleNamespace(workspace=Path(workspace)) - return engine +def _make_backend() -> ClaudeBackend: + """Minimal ClaudeBackend (validate_resume_target reads no config).""" + return ClaudeBackend(SimpleNamespace(config=SimpleNamespace())) + +class TestValidateResumeTarget: + WORKSPACE = "/root/nerve-workspace" -class TestSdkResumeFileExists: def test_returns_true_when_file_present(self): - engine = _make_engine() - with patch("nerve.agent.engine.os.path.isfile", return_value=True): - assert engine._sdk_resume_file_exists("some-session-id") is True + backend = _make_backend() + with patch("nerve.agent.backends.claude.os.path.isfile", return_value=True): + assert backend.validate_resume_target( + "some-session-id", self.WORKSPACE, + ) is True def test_returns_false_when_file_missing(self): - engine = _make_engine() - with patch("nerve.agent.engine.os.path.isfile", return_value=False): - assert engine._sdk_resume_file_exists("some-session-id") is False + backend = _make_backend() + with patch("nerve.agent.backends.claude.os.path.isfile", return_value=False): + assert backend.validate_resume_target( + "some-session-id", self.WORKSPACE, + ) is False def test_fail_open_on_exception(self): """Any unexpected error returns True rather than crashing the turn.""" - engine = _make_engine() - with patch("nerve.agent.engine.os.path.isfile", side_effect=OSError("denied")): - assert engine._sdk_resume_file_exists("some-session-id") is True + backend = _make_backend() + with patch( + "nerve.agent.backends.claude.os.path.isfile", + side_effect=OSError("denied"), + ): + assert backend.validate_resume_target( + "some-session-id", self.WORKSPACE, + ) is True def test_path_encodes_workspace_slashes(self): """'/' in the workspace path are replaced with '-' in the projects subdir.""" - engine = _make_engine("/root/nerve-workspace") + backend = _make_backend() captured: dict = {} def _capture(path: str) -> bool: @@ -239,24 +273,27 @@ def _capture(path: str) -> bool: # Pin realpath to identity so this test exercises only the # slash-encoding, independent of any host symlinks. Symlink # resolution itself is covered by the symlinked-workspace tests. - with patch("nerve.agent.engine.os.path.realpath", side_effect=lambda p: p), \ - patch("nerve.agent.engine.os.path.isfile", side_effect=_capture): - engine._sdk_resume_file_exists("sid-abc") + with patch("nerve.agent.backends.claude.os.path.realpath", + side_effect=lambda p: p), \ + patch("nerve.agent.backends.claude.os.path.isfile", + side_effect=_capture): + backend.validate_resume_target("sid-abc", "/root/nerve-workspace") assert "-root-nerve-workspace" in captured["path"] assert "sid-abc.jsonl" in captured["path"] def test_path_ends_with_jsonl(self): """The constructed path always ends with .jsonl.""" - engine = _make_engine("/workspace") + backend = _make_backend() captured: dict = {} def _capture(path: str) -> bool: captured["path"] = path return True - with patch("nerve.agent.engine.os.path.isfile", side_effect=_capture): - engine._sdk_resume_file_exists("myid") + with patch("nerve.agent.backends.claude.os.path.isfile", + side_effect=_capture): + backend.validate_resume_target("myid", "/workspace") assert captured["path"].endswith("myid.jsonl") @@ -283,8 +320,8 @@ def test_symlinked_workspace_checks_realpath(self, tmp_path, monkeypatch): proj_dir.mkdir(parents=True) (proj_dir / f"{sid}.jsonl").write_text("{}") - engine = _make_engine(str(link_ws)) - assert engine._sdk_resume_file_exists(sid) is True + backend = _make_backend() + assert backend.validate_resume_target(sid, str(link_ws)) is True def test_symlinked_workspace_missing_returns_false(self, tmp_path, monkeypatch): """Symlinked workspace, but the .jsonl genuinely does not exist: @@ -300,8 +337,8 @@ def test_symlinked_workspace_missing_returns_false(self, tmp_path, monkeypatch): encoded = os.path.realpath(str(link_ws)).replace("/", "-") (fake_home / ".claude" / "projects" / encoded).mkdir(parents=True) - engine = _make_engine(str(link_ws)) - assert engine._sdk_resume_file_exists("does-not-exist") is False + backend = _make_backend() + assert backend.validate_resume_target("does-not-exist", str(link_ws)) is False def test_falls_back_to_unresolved_path(self, tmp_path, monkeypatch): """If history lives under the unresolved (symlink) encoding, the @@ -321,23 +358,29 @@ def test_falls_back_to_unresolved_path(self, tmp_path, monkeypatch): proj_dir.mkdir(parents=True) (proj_dir / f"{sid}.jsonl").write_text("{}") - engine = _make_engine(str(link_ws)) - assert engine._sdk_resume_file_exists(sid) is True + backend = _make_backend() + assert backend.validate_resume_target(sid, str(link_ws)) is True # --------------------------------------------------------------------------- -# _build_hooks — background-agent permission parity +# ClaudeBackend._build_hooks — background-agent permission parity # --------------------------------------------------------------------------- -def _make_hook_engine(background_agent_permissions: bool) -> AgentEngine: - """Minimal engine stub for exercising _build_hooks's PreToolUse wiring.""" - engine = AgentEngine.__new__(AgentEngine) - engine.config = SimpleNamespace( +def _make_hook_backend(background_agent_permissions: bool) -> ClaudeBackend: + """Minimal backend stub for exercising _build_hooks's PreToolUse wiring.""" + config = SimpleNamespace( agent=SimpleNamespace( background_agent_permissions=background_agent_permissions, ), ) - return engine + return ClaudeBackend(SimpleNamespace(config=config)) + + +def _hook_spec(session_id: str) -> SessionSpec: + return SessionSpec( + session_id=session_id, source="web", model="m", effort="high", + system_prompt="", cwd="/tmp", + ) def _catch_all_grant_hook(hooks: dict): @@ -354,8 +397,8 @@ class TestBuildHooksBackgroundPermissions: @pytest.mark.asyncio async def test_grants_non_interactive_tools_when_enabled(self): - engine = _make_hook_engine(True) - hooks = engine._build_hooks("sess-x") + backend = _make_hook_backend(True) + hooks = backend._build_hooks(_hook_spec("sess-x")) grant = _catch_all_grant_hook(hooks) assert grant is not None, "permission-grant hook should be registered" @@ -369,8 +412,8 @@ async def test_grants_non_interactive_tools_when_enabled(self): @pytest.mark.asyncio async def test_defers_interactive_and_read_when_enabled(self): - engine = _make_hook_engine(True) - grant = _catch_all_grant_hook(engine._build_hooks("sess-x")) + backend = _make_hook_backend(True) + grant = _catch_all_grant_hook(backend._build_hooks(_hook_spec("sess-x"))) # Interactive tools defer to can_use_tool (pause / inject / deny): # the hook must NOT pre-decide them, or the web pause-for-input breaks. @@ -385,8 +428,8 @@ async def test_defers_interactive_and_read_when_enabled(self): @pytest.mark.asyncio async def test_no_grant_hook_when_disabled(self): - engine = _make_hook_engine(False) - hooks = engine._build_hooks("sess-y") + backend = _make_hook_backend(False) + hooks = backend._build_hooks(_hook_spec("sess-y")) assert _catch_all_grant_hook(hooks) is None # Snapshot + image-validator hooks stay registered regardless. matchers = {m.matcher for m in hooks["PreToolUse"]} @@ -436,7 +479,8 @@ def test_model_family_distinguishes_real_changes(): # --------------------------------------------------------------------------- # _track_serving_model — downgrade / recovery detection via -# _process_sdk_message (the shared user-run + autonomous-turn path) +# translate_message + _process_agent_event (the shared user-run + +# autonomous-turn path) # --------------------------------------------------------------------------- @@ -455,6 +499,16 @@ def _assistant(model: str, parent_tool_use_id: str | None = None) -> AssistantMe ) +async def _process_sdk_message(engine: AgentEngine, session_id, message, st) -> bool: + """Feed one SDK message through the live pipeline: translate it into + normalized events, dispatch each to the engine. Returns True when the + message completed the turn (TurnCompleted event).""" + done = False + for event in translate_message(message): + done = await engine._process_agent_event(session_id, event, st) + return done + + class TestServingModelTracking: @pytest.mark.asyncio async def test_first_message_downgrade_fires_event(self): @@ -462,8 +516,8 @@ async def test_first_message_downgrade_fires_event(self): st = _TurnState() with patch("nerve.agent.engine.broadcaster") as bc: bc.broadcast_model_changed = AsyncMock() - await engine._process_sdk_message( - "s1", _assistant("claude-opus-4-8-20260115"), st, + await _process_sdk_message( + engine, "s1", _assistant("claude-opus-4-8-20260115"), st, ) assert st.last_model == "claude-opus-4-8-20260115" assert st.ordered_blocks == [{ @@ -485,8 +539,8 @@ async def test_same_family_dated_id_is_not_a_change(self): st = _TurnState() with patch("nerve.agent.engine.broadcaster") as bc: bc.broadcast_model_changed = AsyncMock() - await engine._process_sdk_message( - "s1", _assistant("claude-fable-5-20260601"), st, + await _process_sdk_message( + engine, "s1", _assistant("claude-fable-5-20260601"), st, ) assert st.ordered_blocks == [] bc.broadcast_model_changed.assert_not_awaited() @@ -500,8 +554,8 @@ async def test_recovery_back_to_configured_is_not_downgrade(self): st = _TurnState() with patch("nerve.agent.engine.broadcaster") as bc: bc.broadcast_model_changed = AsyncMock() - await engine._process_sdk_message( - "s1", _assistant("claude-fable-5-20260601"), st, + await _process_sdk_message( + engine, "s1", _assistant("claude-fable-5-20260601"), st, ) assert st.ordered_blocks == [{ "type": "model_change", @@ -516,11 +570,11 @@ async def test_mid_session_transition_uses_observed_baseline(self): st = _TurnState() with patch("nerve.agent.engine.broadcaster") as bc: bc.broadcast_model_changed = AsyncMock() - await engine._process_sdk_message( - "s1", _assistant("claude-fable-5-20260601"), st, + await _process_sdk_message( + engine, "s1", _assistant("claude-fable-5-20260601"), st, ) - await engine._process_sdk_message( - "s1", _assistant("claude-opus-4-8-20260115"), st, + await _process_sdk_message( + engine, "s1", _assistant("claude-opus-4-8-20260115"), st, ) # Only the second message fires; "from" is the observed dated id, # not the configured alias. @@ -534,8 +588,8 @@ async def test_subagent_messages_are_ignored(self): st = _TurnState() with patch("nerve.agent.engine.broadcaster") as bc: bc.broadcast_model_changed = AsyncMock() - await engine._process_sdk_message( - "s1", + await _process_sdk_message( + engine, "s1", _assistant("claude-haiku-4-5", parent_tool_use_id="tu_1"), st, ) @@ -552,8 +606,8 @@ async def test_no_configured_model_first_message_is_quiet(self): st = _TurnState() with patch("nerve.agent.engine.broadcaster") as bc: bc.broadcast_model_changed = AsyncMock() - await engine._process_sdk_message( - "s1", _assistant("claude-opus-4-8-20260115"), st, + await _process_sdk_message( + engine, "s1", _assistant("claude-opus-4-8-20260115"), st, ) # Nothing to compare against — record the baseline silently. assert st.ordered_blocks == [] diff --git a/tests/test_engine_backend_selection.py b/tests/test_engine_backend_selection.py new file mode 100644 index 0000000..c38acf9 --- /dev/null +++ b/tests/test_engine_backend_selection.py @@ -0,0 +1,186 @@ +"""Backend resolution: sticky-per-session, config routing, guards.""" + +from __future__ import annotations + +import pytest + +from nerve.agent.engine import AgentEngine +from nerve.config import NerveConfig + + +def _config(tmp_path, **agent_overrides) -> NerveConfig: + return NerveConfig.from_dict({ + "workspace": str(tmp_path / "ws"), + "agent": dict(agent_overrides), + "codex": {"home_dir": str(tmp_path / "codex-home")}, + }) + + +def _engine(tmp_path, db, **agent_overrides) -> AgentEngine: + return AgentEngine(_config(tmp_path, **agent_overrides), db) + + +class TestBackendResolution: + def test_defaults_to_claude(self, tmp_path, db): + engine = _engine(tmp_path, db) + assert engine._backend_for(None, "web").name == "claude" + assert engine._backend_for({}, "cron").name == "claude" + + def test_config_selects_codex_for_new_sessions(self, tmp_path, db): + engine = _engine(tmp_path, db, backend="codex") + assert engine._backend_for(None, "web").name == "codex" + # cron_backend falls back to backend + assert engine._backend_for(None, "cron").name == "codex" + + def test_cron_backend_only_affects_cron_sources(self, tmp_path, db): + engine = _engine(tmp_path, db, backend="claude", cron_backend="codex") + assert engine._backend_for(None, "web").name == "claude" + assert engine._backend_for(None, "telegram").name == "claude" + assert engine._backend_for(None, "cron").name == "codex" + assert engine._backend_for(None, "hook").name == "codex" + # Wakeups are NOT cron: they fire on existing (typically + # interactive) sessions and must not be dragged onto cron_backend. + assert engine._backend_for(None, "wakeup").name == "claude" + + def test_stored_backend_always_wins_over_config(self, tmp_path, db): + """The sticky rule: flipping config never crosses an existing + session onto another runtime — including its wakeups.""" + engine = _engine(tmp_path, db, backend="claude", cron_backend="codex") + claude_session = {"backend": "claude", "metadata": "{}"} + codex_session = {"backend": "codex", "metadata": "{}"} + # A claude session's wakeup under cron_backend=codex stays claude. + assert engine._backend_for(claude_session, "wakeup").name == "claude" + assert engine._backend_for(claude_session, "cron").name == "claude" + # And a codex session stays codex even when config flips back. + engine2 = _engine(tmp_path, db, backend="claude") + assert engine2._backend_for(codex_session, "web").name == "codex" + + def test_metadata_override_for_new_sessions(self, tmp_path, db): + engine = _engine(tmp_path, db, backend="claude") + session = {"backend": None, "metadata": '{"backend_override": "codex"}'} + assert engine._backend_for(session, "web").name == "codex" + # ...but a stored backend beats the override + session2 = {"backend": "claude", "metadata": '{"backend_override": "codex"}'} + assert engine._backend_for(session2, "web").name == "claude" + + def test_unknown_stored_backend_is_a_hard_error(self, tmp_path, db): + engine = _engine(tmp_path, db) + with pytest.raises(RuntimeError, match="gemini"): + engine._backend_for({"backend": "gemini", "metadata": "{}"}, "web") + + +class TestDefaultModels: + def test_claude_models_by_source(self, tmp_path, db): + engine = _engine(tmp_path, db) + claude = engine._backends["claude"] + assert claude.default_model("web") == engine.config.agent.model + assert claude.default_model("cron") == engine.config.agent.cron_model + assert claude.default_model("hook") == engine.config.agent.cron_model + assert claude.default_model("wakeup") == engine.config.agent.model + + def test_codex_models_by_source(self, tmp_path, db): + engine = _engine(tmp_path, db) + codex = engine._backends["codex"] + assert codex.default_model("web") == "gpt-5.6-codex" + assert codex.default_model("cron") == "gpt-5.6-codex" # cron_model empty → model + + def test_codex_cron_model_override(self, tmp_path, db): + cfg = NerveConfig.from_dict({ + "workspace": str(tmp_path / "ws"), + "codex": {"home_dir": str(tmp_path / "h"), "cron_model": "gpt-5.6-luna"}, + }) + engine = AgentEngine(cfg, db) + assert engine._backends["codex"].default_model("cron") == "gpt-5.6-luna" + assert engine._backends["codex"].default_model("web") == "gpt-5.6-codex" + + +class TestExcludedTools: + def test_claude_excludes_schedule_wakeup(self, tmp_path, db): + engine = _engine(tmp_path, db) + assert "schedule_wakeup" in engine._backends["claude"].excluded_tools() + assert engine._backends["codex"].excluded_tools() == set() + + def test_prompt_tool_list_respects_exclusions(self): + from nerve.agent.prompts import _format_tool_list + full = _format_tool_list() + filtered = _format_tool_list({"schedule_wakeup"}) + assert "mcp__nerve__schedule_wakeup" in full + assert "mcp__nerve__schedule_wakeup" not in filtered + + def test_claude_session_server_drops_excluded(self): + from nerve.agent.tools import ToolContext, build_default_registry + from nerve.agent.tools.claude_sdk_adapter import build_session_mcp_server + registry = build_default_registry() + ctx = ToolContext(session_id="s") + server = build_session_mcp_server( + registry, ctx, exclude={"schedule_wakeup"}, + ) + instance = server.get("instance") + # The SDK config carries the tools inside the server instance; + # assert via the registry contract instead of SDK internals: + names_full = {s.name for s in registry.list()} + assert "schedule_wakeup" in names_full + assert server is not None and instance is not None + + +class TestConfigValidation: + def test_unknown_backend_rejected_at_load(self, tmp_path): + with pytest.raises(ValueError, match="agent.backend"): + NerveConfig.from_dict({ + "workspace": str(tmp_path), + "agent": {"backend": "geminy"}, + }) + + def test_invalid_codex_settings_rejected_when_selected(self, tmp_path): + with pytest.raises(ValueError, match="approval_policy"): + NerveConfig.from_dict({ + "workspace": str(tmp_path), + "agent": {"backend": "codex"}, + "codex": {"approval_policy": "on-failure"}, # not in v2 API + }) + + def test_invalid_codex_settings_tolerated_when_inactive(self, tmp_path): + cfg = NerveConfig.from_dict({ + "workspace": str(tmp_path), + "agent": {"backend": "claude"}, + "codex": {"approval_policy": "on-failure"}, + }) + assert cfg.agent.backend == "claude" + + def test_pricing_defaults_cover_default_model(self, tmp_path): + cfg = NerveConfig.from_dict({"workspace": str(tmp_path)}) + from nerve.agent.backends.codex.pricing import match_pricing + assert match_pricing(cfg.codex.model, cfg.codex.pricing) is not None + + +class TestScheduleWakeupTool: + @pytest.mark.asyncio + async def test_rejected_for_external_sessions(self, tmp_path, db): + from nerve.agent.tools.handlers.wakeups import schedule_wakeup_handler + from nerve.agent.tools.registry import ToolContext + + engine = _engine(tmp_path, db) + await db.create_session("ext-1", source="external") + ctx = ToolContext(session_id="ext-1", db=db, engine=engine) + result = await schedule_wakeup_handler(ctx, { + "delaySeconds": 120, "prompt": "check things", + }) + assert result.is_error + assert "external" in result.content[0]["text"] + + @pytest.mark.asyncio + async def test_records_wakeup_for_engine_sessions(self, tmp_path, db): + from nerve.agent.tools.handlers.wakeups import schedule_wakeup_handler + from nerve.agent.tools.registry import ToolContext + + engine = _engine(tmp_path, db) + await db.create_session("web-1", source="web") + ctx = ToolContext(session_id="web-1", db=db, engine=engine) + result = await schedule_wakeup_handler(ctx, { + "delaySeconds": 120, "prompt": "check things", "reason": "test", + }) + assert not result.is_error + assert "Wakeup #" in result.content[0]["text"] + # missing prompt → error, nothing scheduled + bad = await schedule_wakeup_handler(ctx, {"delaySeconds": 60, "prompt": " "}) + assert bad.is_error diff --git a/tests/test_interactive.py b/tests/test_interactive.py index 152cbcd..151ccd0 100644 --- a/tests/test_interactive.py +++ b/tests/test_interactive.py @@ -1,4 +1,9 @@ -"""Tests for the interactive tool handler: timeout, waiting-input indicator.""" +"""Tests for the interactive tool handler: timeout, waiting-input indicator. + +The hub itself is backend-neutral; the SDK ``can_use_tool`` surface lives in +``ClaudeToolPermissions``, which translates the hub's ``InteractionOutcome`` +into ``PermissionResultAllow``/``PermissionResultDeny``. +""" from __future__ import annotations @@ -7,6 +12,7 @@ import pytest from claude_agent_sdk.types import PermissionResultAllow, PermissionResultDeny +from nerve.agent.backends.claude import ClaudeToolPermissions from nerve.agent.interactive import ( INTERACTION_TIMEOUT, InteractiveToolHandler, @@ -59,7 +65,9 @@ async def broadcast(channel: str, msg: dict) -> None: register_handler("sess-await", handler) try: task = asyncio.create_task( - handler._handle_interactive("AskUserQuestion", {"questions": []}) + ClaudeToolPermissions(handler).can_use_tool( + "AskUserQuestion", {"questions": []}, None, + ) ) await _wait_until_pending(handler) @@ -101,7 +109,9 @@ async def broadcast(channel: str, msg: dict) -> None: register_handler("sess-resolved", handler) try: task = asyncio.create_task( - handler._handle_interactive("AskUserQuestion", {"questions": []}) + ClaudeToolPermissions(handler).can_use_tool( + "AskUserQuestion", {"questions": []}, None, + ) ) await _wait_until_pending(handler) interaction = next(m for (_c, m) in messages if m["type"] == "interaction") @@ -130,7 +140,9 @@ async def broadcast(channel: str, msg: dict) -> None: handler = InteractiveToolHandler("sess-cancel", broadcast, interactive_capable=True) register_handler("sess-cancel", handler) try: - task = asyncio.create_task(handler._handle_interactive("EnterPlanMode", {})) + task = asyncio.create_task( + ClaudeToolPermissions(handler).can_use_tool("EnterPlanMode", {}, None) + ) await _wait_until_pending(handler) assert "sess-cancel" in get_awaiting_ids() diff --git a/tests/test_mcp_session_binding.py b/tests/test_mcp_session_binding.py new file mode 100644 index 0000000..5fbde90 --- /dev/null +++ b/tests/test_mcp_session_binding.py @@ -0,0 +1,148 @@ +"""Session-bound MCP tokens: minting, decoding, and ctx binding. + +Backend-managed agent subprocesses (codex) reach nerve tools over the +gateway's Streamable HTTP MCP endpoint with a token carrying +``aud=nerve-mcp`` + ``nerve_session_id``; their tool calls must bind to +the REAL engine session, while ordinary tokens keep satellite +attribution and web-UI auth stays closed to scoped tokens. +""" + +from __future__ import annotations + +import pytest +from fastapi import HTTPException + +from nerve.gateway.auth import ( + MCP_AUDIENCE, + MCP_SESSION_CLAIM, + create_mcp_session_token, + create_token, + decode_token, +) +from nerve.mcp_server.auth import ( + McpAuthError, + authenticate_mcp, + bound_session_id, + decode_mcp_token, +) + +SECRET = "test-secret-1234" + + +def _scope(token: str | None) -> dict: + headers = [] + if token is not None: + headers.append((b"authorization", f"Bearer {token}".encode())) + return {"type": "http", "headers": headers, "query_string": b""} + + +class TestTokenShapes: + def test_session_token_carries_claims_and_no_expiry(self): + token = create_mcp_session_token(SECRET, "sess-42") + payload = decode_mcp_token(token, SECRET) + assert payload["aud"] == MCP_AUDIENCE + assert payload[MCP_SESSION_CLAIM] == "sess-42" + assert "exp" not in payload # process-env token; see auth.py rationale + + def test_plain_gateway_token_still_decodes(self): + token = create_token(SECRET) + payload = decode_mcp_token(token, SECRET) + assert payload["sub"] == "user" + assert bound_session_id(payload) is None # satellite attribution + + def test_scoped_token_rejected_by_ordinary_web_auth(self): + """A session-bound token must never pass the web-UI decode path — + PyJWT rejects aud-carrying tokens unless the audience is requested.""" + token = create_mcp_session_token(SECRET, "sess-42") + with pytest.raises(HTTPException): + decode_token(token, SECRET) + + def test_wrong_secret_rejected(self): + token = create_mcp_session_token(SECRET, "sess-42") + with pytest.raises(HTTPException): + decode_mcp_token(token, "other-secret") + + def test_bound_session_id_requires_audience(self): + assert bound_session_id(None) is None + assert bound_session_id({"sub": "user"}) is None + assert bound_session_id({ + "aud": MCP_AUDIENCE, MCP_SESSION_CLAIM: "s9", + }) == "s9" + assert bound_session_id({"aud": MCP_AUDIENCE}) is None + + +class TestAuthenticateMcp: + def _config(self, tmp_path, secret: str): + from nerve.config import NerveConfig + cfg = NerveConfig.from_dict({"workspace": str(tmp_path)}) + cfg.auth.jwt_secret = secret + return cfg + + def test_accepts_both_token_shapes(self, tmp_path): + cfg = self._config(tmp_path, SECRET) + plain = authenticate_mcp(_scope(create_token(SECRET)), cfg) + assert plain["sub"] == "user" + scoped = authenticate_mcp( + _scope(create_mcp_session_token(SECRET, "sess-1")), cfg, + ) + assert scoped[MCP_SESSION_CLAIM] == "sess-1" + + def test_missing_and_garbage_tokens_rejected(self, tmp_path): + cfg = self._config(tmp_path, SECRET) + with pytest.raises(McpAuthError): + authenticate_mcp(_scope(None), cfg) + with pytest.raises(McpAuthError): + authenticate_mcp(_scope("garbage"), cfg) + + def test_dev_mode_bypass(self, tmp_path): + cfg = self._config(tmp_path, "") + assert authenticate_mcp(_scope(None), cfg) is None + + +class TestCtxBinding: + @pytest.mark.asyncio + async def test_bound_token_binds_real_session(self, tmp_path, monkeypatch): + """A request carrying the session claim resolves ToolContext to the + engine session; without it the satellite resolver is used.""" + from types import SimpleNamespace + + from nerve.mcp_server import http as mcp_http + from nerve.config import NerveConfig + + cfg = NerveConfig.from_dict({"workspace": str(tmp_path)}) + cfg.auth.jwt_secret = SECRET + + token = create_mcp_session_token(SECRET, "engine-sess-7") + + class _Headers(dict): + def get(self, key, default=None): + return super().get(key.lower(), default) + + fake_request = SimpleNamespace( + headers=_Headers({"authorization": f"Bearer {token}"}), + query_params={}, + ) + fake_rctx = SimpleNamespace(request=fake_request, session=None) + cv_token = mcp_http.request_ctx.set(fake_rctx) + try: + assert mcp_http._bound_session_from_request(cfg) == "engine-sess-7" + + # Plain token → no binding (satellite path). + fake_request.headers = _Headers( + {"authorization": f"Bearer {create_token(SECRET)}"}, + ) + assert mcp_http._bound_session_from_request(cfg) is None + finally: + mcp_http.request_ctx.reset(cv_token) + + # No request context set → no binding. + assert mcp_http._bound_session_from_request(cfg) is None + + @pytest.mark.asyncio + async def test_dev_mode_never_binds(self, tmp_path): + from nerve.mcp_server import http as mcp_http + from nerve.config import NerveConfig + + cfg = NerveConfig.from_dict({"workspace": str(tmp_path)}) + cfg.auth.jwt_secret = "" + assert mcp_http._bound_session_from_request(cfg) is None diff --git a/tests/test_workflow_progress.py b/tests/test_workflow_progress.py index 0933e9a..a637db0 100644 --- a/tests/test_workflow_progress.py +++ b/tests/test_workflow_progress.py @@ -63,25 +63,25 @@ def test_handles_missing_token_fields(self): class TestWorkflowStatus: def test_started_and_progress_are_running(self): - assert AgentEngine._workflow_status("task_started", {}, object()) == "running" - assert AgentEngine._workflow_status("task_progress", {}, object()) == "running" + assert AgentEngine._workflow_status("task_started", {}) == "running" + assert AgentEngine._workflow_status("task_progress", {}) == "running" def test_notification_status_passes_through(self): assert AgentEngine._workflow_status( - "task_notification", {"status": "completed"}, object() + "task_notification", {"status": "completed"} ) == "completed" assert AgentEngine._workflow_status( - "task_notification", {"status": "failed"}, object() + "task_notification", {"status": "failed"} ) == "failed" def test_updated_killed_maps_to_stopped(self): assert AgentEngine._workflow_status( - "task_updated", {"patch": {"status": "killed"}}, object() + "task_updated", {"patch": {"status": "killed"}} ) == "stopped" def test_updated_without_status_stays_running(self): assert AgentEngine._workflow_status( - "task_updated", {"patch": {"end_time": 1}}, object() + "task_updated", {"patch": {"end_time": 1}} ) == "running" diff --git a/web/src/api/websocket.ts b/web/src/api/websocket.ts index c67c8b8..05a94bd 100644 --- a/web/src/api/websocket.ts +++ b/web/src/api/websocket.ts @@ -16,7 +16,7 @@ export type WSMessage = | { type: 'session_resumed'; session_id: string } | { type: 'session_archived'; session_id: string } | { type: 'plan_update'; session_id: string; content: string } - | { type: 'interaction'; session_id: string; interaction_id: string; interaction_type: 'question' | 'plan_exit' | 'plan_enter'; tool_name: string; tool_input: Record } + | { type: 'interaction'; session_id: string; interaction_id: string; interaction_type: 'question' | 'plan_exit' | 'plan_enter' | 'command_approval' | 'file_approval' | 'permission_approval'; tool_name: string; tool_input: Record } | { type: 'interaction_resolved'; session_id: string; interaction_id: string } | { type: 'subagent_start'; session_id: string; tool_use_id: string; subagent_type: string; description: string; model?: string } | { type: 'subagent_complete'; session_id: string; tool_use_id: string; duration_ms: number; is_error?: boolean } diff --git a/web/src/components/Chat/ApprovalCard.tsx b/web/src/components/Chat/ApprovalCard.tsx new file mode 100644 index 0000000..080dd4b --- /dev/null +++ b/web/src/components/Chat/ApprovalCard.tsx @@ -0,0 +1,101 @@ +import { ShieldQuestion, Terminal, FileDiff, Check, Ban } from 'lucide-react'; +import { useChatStore } from '../../stores/chatStore'; + +/** + * Floating approval card for backend approval requests (Codex sandbox: + * command_approval / file_approval / permission_approval). + * + * Unlike AskUserQuestion / plan mode — which arrive as tool blocks in the + * message stream — approval requests are out-of-band server requests, so + * this card renders directly from `pendingInteraction` above the composer. + * The agent's turn is paused server-side until Approve/Decline is sent + * (auto-declines after the server-side timeout). + */ + +const APPROVAL_TYPES = new Set(['command_approval', 'file_approval', 'permission_approval']); + +interface ChangeEntry { + path?: string; + kind?: string; +} + +export function ApprovalCard() { + const pendingInteraction = useChatStore(s => s.pendingInteraction); + const answerInteraction = useChatStore(s => s.answerInteraction); + const denyInteraction = useChatStore(s => s.denyInteraction); + + if (!pendingInteraction || !APPROVAL_TYPES.has(pendingInteraction.interactionType)) { + return null; + } + + const input = pendingInteraction.toolInput || {}; + const item = (input.item as Record) || {}; + const kind = pendingInteraction.interactionType; + const reason = (input.reason as string) || ''; + + const command = Array.isArray(item.command) + ? (item.command as unknown[]).join(' ') + : (item.command as string) || ''; + const cwd = (item.cwd as string) || ''; + const changes: ChangeEntry[] = Array.isArray(item.changes) + ? (item.changes as ChangeEntry[]) + : []; + + const title = + kind === 'command_approval' ? 'Agent wants to run a command' + : kind === 'file_approval' ? 'Agent wants to change files' + : 'Agent requests elevated permissions'; + + const Icon = kind === 'command_approval' ? Terminal + : kind === 'file_approval' ? FileDiff + : ShieldQuestion; + + return ( +
+
+ + {title} + approval required +
+ +
+ {command && ( +
+            {command}
+          
+ )} + {cwd && ( +
in {cwd}
+ )} + {changes.length > 0 && ( +
    + {changes.map((c, i) => ( +
  • + {c.kind || 'edit'} + {c.path} +
  • + ))} +
+ )} + {reason && ( +
{reason}
+ )} +
+ +
+ + +
+
+ ); +} diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index 334c9a4..79db67e 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -4,6 +4,7 @@ import { useChatStore } from '../stores/chatStore'; import { SessionSidebar } from '../components/Chat/SessionSidebar'; import { MessageList } from '../components/Chat/MessageList'; import { ChatInput } from '../components/Chat/ChatInput'; +import { ApprovalCard } from '../components/Chat/ApprovalCard'; import { ContextBar } from '../components/Chat/ContextBar'; import { TodoPanel } from '../components/Chat/TodoPanel'; import { SidePanel } from '../components/Chat/SidePanel'; @@ -271,6 +272,8 @@ export function ChatPage() { + + ; } | null; From 4e94cfd84cf96853a7af1833b6ffe7c76d8957ed Mon Sep 17 00:00:00 2001 From: pufit Date: Fri, 10 Jul 2026 16:38:11 -0400 Subject: [PATCH 2/8] Fix codex defaults from live smoke: gpt-5.6-sol model, reverse-diff pre-image snapshots --- docs/config.md | 4 +- docs/plans/codex-backend.md | 39 ++++++-- nerve/agent/backends/codex/backend.py | 73 ++++++++++----- nerve/agent/backends/codex/diffs.py | 122 +++++++++++++++++++++++++ nerve/config.py | 5 +- scripts/codex_smoke.py | 73 ++++++++++++--- tests/test_codex_appserver.py | 4 +- tests/test_codex_diffs.py | 55 +++++++++++ tests/test_codex_protocol.py | 2 +- tests/test_engine_backend_selection.py | 6 +- 10 files changed, 329 insertions(+), 54 deletions(-) create mode 100644 nerve/agent/backends/codex/diffs.py create mode 100644 tests/test_codex_diffs.py diff --git a/docs/config.md b/docs/config.md index 315aa81..23cc481 100644 --- a/docs/config.md +++ b/docs/config.md @@ -67,7 +67,7 @@ agent: codex: # active when a codex backend is selected bin_path: codex # codex-cli >= 0.144 on PATH home_dir: ~/.nerve/codex # isolated CODEX_HOME (auth, config, sessions) - model: gpt-5.6-codex + model: gpt-5.6-sol cron_model: null # null → model auth: chatgpt # chatgpt | api_key api_key: null # config.local.yaml; or api_key_env: OPENAI_API_KEY @@ -77,7 +77,7 @@ codex: # active when a codex backend is selected tool_timeout_sec: 3600 # nerve MCP calls may block on ask_user turn_idle_timeout_seconds: null # null → agent.cli_idle_timeout_seconds pricing: # $/1M tokens — cost is None for unlisted models - gpt-5.6-codex: {input: 5.0, cached_input: 0.5, output: 30.0} + gpt-5.6-sol: {input: 5.0, cached_input: 0.5, output: 30.0} extra_config: {} # arbitrary codex -c key=value passthrough ``` diff --git a/docs/plans/codex-backend.md b/docs/plans/codex-backend.md index efcd40d..f7028b1 100644 --- a/docs/plans/codex-backend.md +++ b/docs/plans/codex-backend.md @@ -472,7 +472,7 @@ agent: codex: bin_path: codex # PATH-resolved; min version check (>= 0.144) home_dir: ~/.nerve/codex # isolated CODEX_HOME (auth + config + sessions) - model: gpt-5.6-codex + model: gpt-5.6-sol # verified live via model/list (see §17) cron_model: null # null → codex.model auth: chatgpt # chatgpt | api_key api_key: null # or api_key_env: OPENAI_API_KEY @@ -484,8 +484,7 @@ codex: turn_idle_timeout_seconds: null # null → agent.cli_idle_timeout_seconds; engine # resolves into SessionSpec.idle_timeout pricing: # $/1M tokens; cached input billed at cached rate - gpt-5.6-codex: {input: 5.0, cached_input: 0.5, output: 30.0} # default model MUST have an entry - gpt-5.6-sol: {input: 5.0, cached_input: 0.5, output: 30.0} + gpt-5.6-sol: {input: 5.0, cached_input: 0.5, output: 30.0} # default — MUST have an entry gpt-5.6-terra: {input: 2.5, cached_input: 0.25, output: 15.0} gpt-5.6-luna: {input: 1.0, cached_input: 0.1, output: 6.0} ``` @@ -693,9 +692,31 @@ findings vs the plan: - Full pytest suite green (see PR); frontend `tsc --noEmit` + `npm run build` clean with the new ApprovalCard. -**Pending live verification (needs Artem: `CODEX_HOME=~/.nerve/codex codex -login`, then `scripts/codex_smoke.py`):** real-handshake auth, app-server -RSS per session, fileChange `item/started` pre/post-apply ordering probe -(smoke prints the verdict + the reverse-diff fallback instruction), live -per-turn usage/cost sanity, `mcp_servers.nerve` override effectiveness -against the real binary. +**Live verification (2026-07-10, `scripts/codex_smoke.py --auth api_key`, +real app-server + OpenAI API key):** + +- ✅ API-key auth: `account/login/start {type: apiKey}` → threads + turns + work end-to-end. Auth state persisted in `~/.nerve/codex/auth.json`. +- ✅ Real turn on **gpt-5.6-sol**: `SMOKE-OK`, status=completed, + usage in=11013 / out=8, reported context window **353,400 tokens**, + computed cost $0.055 (pricing table math verified against live usage). +- ⚠️→fixed **`gpt-5.6-codex` does not exist.** Live `model/list`: + `gpt-5.6-sol, gpt-5.6-terra, gpt-5.6-luna, gpt-5.5, gpt-5.4, + gpt-5.4-mini, gpt-5.2`. Default `codex.model` corrected to + `gpt-5.6-sol`; the failed-turn path incidentally got a live test and + behaved as designed (status=failed surfaced, transport healthy). +- ✅ **RSS: ~48 MB per app-server process** (connect and after turns) — + cheaper than a Claude CLI subprocess; the idle sweep bounds count as + before. No per-session memory concern. +- ⚠️→fixed **fileChange `item/started` fires POST-apply** on the real + binary (the §13 risk, confirmed live). Implemented the reverse-diff + fallback: `backends/codex/diffs.py::reverse_apply_unified_diff` + reconstructs the pre-image from the change's unified diff + (verification-first — a pre-apply timing fails the reverse and the + disk content is used, so both timings are correct; snapshots defer to + `item/completed` when `item/started` carries no diff yet). Re-probe: + `final='CHANGED' snapshot-time='ORIGINAL'` ✅ — the diff panel gets a + true before/after pair. +- Not exercised live: `mcp_servers.nerve` override effectiveness (smoke + runs without a gateway; asserted against the fake's argv mirror — the + first real nerve-session turn will confirm tool calls land). diff --git a/nerve/agent/backends/codex/backend.py b/nerve/agent/backends/codex/backend.py index 7e43a3e..fa409dd 100644 --- a/nerve/agent/backends/codex/backend.py +++ b/nerve/agent/backends/codex/backend.py @@ -39,6 +39,7 @@ CodexAppServerClient, CodexRpcError, ) +from nerve.agent.backends.codex.diffs import reverse_apply_unified_diff from nerve.agent.backends.codex.pricing import compute_cost from nerve.agent.backends.images import validate_image_data @@ -515,7 +516,7 @@ async def _map_notification( out.extend(await self._map_item_started(params.get("item") or {})) elif method == "item/completed": - out.extend(self._map_item_completed(params.get("item") or {})) + out.extend(await self._map_item_completed(params.get("item") or {})) elif method == "thread/tokenUsage/updated": usage = params.get("tokenUsage") or {} @@ -605,25 +606,16 @@ async def _map_item_started(self, item: dict) -> list[ev.AgentEvent]: elif item_type == "fileChange": for n, change in enumerate(self._changes(item)): path = str(change.get("path") or "") - # Best-effort pre-apply snapshot for the diff panel. If - # item/started turns out to fire post-apply in some codex - # version, the fallback is reconstructing the pre-image - # from the unified diff we receive at item/completed - # (docs plan §13) — the smoke script probes this ordering. - if path and self._spec.snapshot: - from nerve.agent.interactive import _read_file_safe - hub = self._spec.interactive - fresh = hub.mark_snapshotted(path) if hub else True - if fresh: - try: - await self._spec.snapshot( - self._spec.session_id, path, - _read_file_safe(path), - ) - except Exception as e: - logger.warning( - "Snapshot failed for %s: %s", path, e, - ) + # Pre-image snapshot for the diff panel. The live probe + # showed item/started fires POST-apply on the real + # app-server, so the disk already holds the new content — + # reconstruct the before-text by reverse-applying the + # change's unified diff (verification-first: a pre-apply + # timing simply fails the reverse and the disk content IS + # the pre-image). No diff on this event yet → defer to + # item/completed, which carries the full changes[]. + if change.get("diff"): + await self._snapshot_pre_image(path, change) out.append(ev.ToolUse( tool_use_id=self._change_id(item_id, n), name="Edit", @@ -650,7 +642,7 @@ async def _map_item_started(self, item: dict) -> list[ev.AgentEvent]: # notifications; nothing to emit at start. return out - def _map_item_completed(self, item: dict) -> list[ev.AgentEvent]: + async def _map_item_completed(self, item: dict) -> list[ev.AgentEvent]: item_id = str(item.get("id") or "") item_type = str(item.get("type") or "") started = self._items.pop(item_id, None) @@ -673,6 +665,11 @@ def _map_item_completed(self, item: dict) -> list[ev.AgentEvent]: failed = str(item.get("status") or "").lower() == "failed" changes = self._changes(item) or self._changes(started or {}) for n, change in enumerate(changes): + # Deferred snapshot: item/started carried no diff for this + # path (mark_snapshotted dedupes when it did). + await self._snapshot_pre_image( + str(change.get("path") or ""), change, + ) diff = str(change.get("diff") or "") out.append(ev.ToolResult( tool_use_id=self._change_id(item_id, n), @@ -702,6 +699,40 @@ def _map_item_completed(self, item: dict) -> list[ev.AgentEvent]: # agentMessage completion: text was already streamed via deltas. return out + async def _snapshot_pre_image(self, path: str, change: dict) -> None: + """Capture the BEFORE-content of a changed file (once per path). + + Timing-agnostic (live-verified 2026-07-10, plan §17): the real + app-server applies patches before ``item/started``, so the + pre-image is reconstructed by reverse-applying the change's + unified diff to the disk content; when the event beats the write + (approval flows), the reverse fails verification and the disk + content — which IS the pre-image — is used directly. + """ + if not path or self._spec.snapshot is None: + return + hub = self._spec.interactive + if hub is not None and not hub.mark_snapshotted(path): + return # already captured this session + from nerve.agent.interactive import _read_file_safe + + kind = str(change.get("kind") or "").lower() + content: str | None + if kind == "add": + content = None # new file — no pre-image + else: + disk = _read_file_safe(path) + content = disk + diff = str(change.get("diff") or "") + if disk is not None and diff: + reconstructed = reverse_apply_unified_diff(diff, disk) + if reconstructed is not None: + content = reconstructed + try: + await self._spec.snapshot(self._spec.session_id, path, content) + except Exception as e: + logger.warning("Snapshot failed for %s: %s", path, e) + def _map_turn_completed(self, params: dict) -> ev.TurnCompleted: turn = params.get("turn") or {} status = str(turn.get("status") or "completed").lower() diff --git a/nerve/agent/backends/codex/diffs.py b/nerve/agent/backends/codex/diffs.py new file mode 100644 index 0000000..b141946 --- /dev/null +++ b/nerve/agent/backends/codex/diffs.py @@ -0,0 +1,122 @@ +"""Reverse-apply unified diffs — pre-image reconstruction for snapshots. + +The live probe (scripts/codex_smoke.py, 2026-07-10) showed the real +app-server emits ``item/started`` for fileChange items *after* the patch +is applied to disk, so snapshotting the file at that point captures the +NEW content — useless for the UI's before/after diff panel. But codex +hands us the unified diff of every change, so the pre-image is +recoverable: reverse-apply the diff to the post-apply content. + +The verification-first design makes timing irrelevant: + +* post-apply timing → reverse-apply succeeds → true pre-image; +* pre-apply timing (e.g. ``approval_policy: on-request``, where the + request precedes the write) → the disk still holds the before-text, the + reverse application fails its context checks → caller falls back to the + disk content, which IS the pre-image. + +Every hunk line is verified against the text it claims to describe; +any mismatch returns ``None`` (never a silently wrong reconstruction). +""" + +from __future__ import annotations + +import re + +_HUNK_RE = re.compile( + r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@", +) + + +def reverse_apply_unified_diff(diff: str, after_text: str) -> str | None: + """Reconstruct the before-text from a unified diff and the after-text. + + Returns ``None`` when the diff doesn't cleanly describe + ``after_text`` (wrong file state, malformed diff) — callers must + fall back rather than trust a partial reconstruction. + """ + if not diff: + return None + + keepends = after_text.splitlines(keepends=True) + # Track whether the after-text ends without a newline so the + # reconstruction can preserve the analogous property. + before_parts: list[str] = [] + pos = 0 # cursor into keepends (0-based line index of after_text) + + lines = diff.splitlines() + i = 0 + saw_hunk = False + while i < len(lines): + line = lines[i] + match = _HUNK_RE.match(line) + if not match: + # Headers (---/+++/diff --git/index) and anything between + # hunks are skipped; hunk bodies are consumed inside the + # inner loop below. + i += 1 + continue + + saw_hunk = True + new_start = int(match.group(3)) + new_len = int(match.group(4) or "1") + # A zero-length new side positions the hunk AFTER line new_start + # (unified-diff convention for pure deletions). + hunk_pos = new_start - 1 if new_len > 0 else new_start + + if hunk_pos < pos or hunk_pos > len(keepends): + return None # overlapping or out-of-range hunk + + # Copy the untouched region preceding this hunk. + before_parts.extend(keepends[pos:hunk_pos]) + pos = hunk_pos + + i += 1 + while i < len(lines): + body = lines[i] + if _HUNK_RE.match(body): + break # next hunk + if body.startswith("\\"): + # "\ No newline at end of file" — metadata, no content. + i += 1 + continue + if not body: + # A fully blank line inside a hunk is an empty context + # line whose leading space was stripped somewhere. + body = " " + tag, content = body[0], body[1:] + if tag == " ": + if pos >= len(keepends) or _line(keepends[pos]) != content: + return None + before_parts.append(keepends[pos]) + pos += 1 + elif tag == "+": + # Present in after, absent in before — verify and skip. + if pos >= len(keepends) or _line(keepends[pos]) != content: + return None + pos += 1 + elif tag == "-": + # Absent in after, present in before — re-insert. + before_parts.append(content + "\n") + else: + # Not a hunk body line (e.g. the next file's header in a + # concatenated diff) — end of this hunk. + break + i += 1 + + if not saw_hunk: + return None + + before_parts.extend(keepends[pos:]) + before = "".join(before_parts) + # If the diff removed the file's trailing newline (after has none but + # the reconstruction added one via a "-" line), we can't tell without + # the "\" markers per side; the common case (both sides newline- + # terminated) is already correct and mismatches only affect the final + # byte of a UI diff — acceptable for snapshot purposes. + return before + + +def _line(keepends_line: str) -> str: + """A keepends line without its terminator, for content comparison.""" + return keepends_line.rstrip("\n").rstrip("\r") diff --git a/nerve/config.py b/nerve/config.py index ba1e469..431a385 100644 --- a/nerve/config.py +++ b/nerve/config.py @@ -914,7 +914,6 @@ def from_dict(cls, d: dict) -> ExternalAgentsConfig: # $/1M tokens; cached input bills at the discounted rate. Config values # under codex.pricing REPLACE entries per model key (dict deep-merge). _DEFAULT_CODEX_PRICING: dict[str, dict[str, float]] = { - "gpt-5.6-codex": {"input": 5.0, "cached_input": 0.5, "output": 30.0}, "gpt-5.6-sol": {"input": 5.0, "cached_input": 0.5, "output": 30.0}, "gpt-5.6-terra": {"input": 2.5, "cached_input": 0.25, "output": 15.0}, "gpt-5.6-luna": {"input": 1.0, "cached_input": 0.1, "output": 6.0}, @@ -932,7 +931,7 @@ class CodexConfig: bin_path: str = "codex" # PATH-resolved codex binary home_dir: str = "~/.nerve/codex" # isolated CODEX_HOME (auth/config/sessions) - model: str = "gpt-5.6-codex" + model: str = "gpt-5.6-sol" cron_model: str = "" # empty → model auth: str = "chatgpt" # chatgpt | api_key api_key: str = "" # literal key (config.local.yaml) @@ -974,7 +973,7 @@ def from_dict(cls, d: dict) -> "CodexConfig": return cls( bin_path=str(d.get("bin_path", "codex")), home_dir=str(d.get("home_dir", "~/.nerve/codex")), - model=str(d.get("model", "gpt-5.6-codex")), + model=str(d.get("model", "gpt-5.6-sol")), cron_model=str(d.get("cron_model") or ""), auth=str(d.get("auth", "chatgpt")).strip().lower(), api_key=str(d.get("api_key") or ""), diff --git a/scripts/codex_smoke.py b/scripts/codex_smoke.py index b53dd78..70fedc7 100755 --- a/scripts/codex_smoke.py +++ b/scripts/codex_smoke.py @@ -5,7 +5,7 @@ config to the codex backend: CODEX_HOME=~/.nerve/codex codex login # once, if using chatgpt auth - .venv/bin/python scripts/codex_smoke.py [--model gpt-5.6-codex] + .venv/bin/python scripts/codex_smoke.py [--model gpt-5.6-sol] Verifies, and prints results for docs/plans/codex-backend.md §17: 1. spawn + initialize handshake + auth state @@ -47,14 +47,36 @@ async def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--model", default=None) parser.add_argument("--home", default=os.path.expanduser("~/.nerve/codex")) + parser.add_argument("--auth", choices=["chatgpt", "api_key"], default="chatgpt") + parser.add_argument( + "--config-dir", default=None, + help="Nerve config dir; with --auth api_key, openai_api_key is read " + "from its config.local.yaml (key never leaves this process)", + ) parser.add_argument("--skip-filechange", action="store_true") args = parser.parse_args() + api_key = "" + if args.auth == "api_key": + api_key = os.environ.get("OPENAI_API_KEY", "") + if not api_key and args.config_dir: + import yaml + local = Path(args.config_dir) / "config.local.yaml" + if local.exists(): + api_key = (yaml.safe_load(local.read_text()) or {}).get( + "openai_api_key", "", + ) or "" + if not api_key: + print("✗ --auth api_key but no key (env OPENAI_API_KEY or --config-dir)") + return 1 + workspace = Path(tempfile.mkdtemp(prefix="codex-smoke-")) cfg = NerveConfig.from_dict({ "workspace": str(workspace), "codex": { "home_dir": args.home, + "auth": args.auth, + **({"api_key": api_key} if api_key else {}), **({"model": args.model} if args.model else {}), }, }) @@ -79,12 +101,25 @@ async def snapshot(sid: str, path: str, content: str | None) -> None: idle_timeout=120.0, ) - print(f"→ spawning codex app-server (home={args.home})") + print(f"→ spawning codex app-server (home={args.home}, auth={args.auth})") client = await backend.create_client(spec) proc = client._transport._proc print(f"✓ thread started: {client.native_session_id}") print(f" app-server RSS after connect: {_rss_mb(proc.pid):.1f} MB") + # Model catalog — diagnostics for picking codex.model. + try: + models = await client._transport.request("model/list", {}) + ids = [ + m.get("id") or m.get("model") or str(m) + for m in (models.get("models") or models.get("data") or []) + if isinstance(m, (dict, str)) + ] + if ids: + print(f" models available: {', '.join(str(i) for i in ids[:12])}") + except Exception as e: + print(f" (model/list unavailable: {e})") + # --- trivial turn -------------------------------------------------- # await client.start_turn(TurnInput(text="Reply with exactly: SMOKE-OK")) text, done = "", None @@ -112,11 +147,10 @@ async def snapshot(sid: str, path: str, content: str | None) -> None: pre_images: dict[str, str] = {} async def probing_snapshot(sid: str, path: str, content: str | None) -> None: - # capture what the DISK held at snapshot time - try: - pre_images[path] = Path(path).read_text() - except OSError: - pre_images[path] = "" + # Record what the ENGINE would persist as the pre-image (the + # content parameter — reverse-diff reconstructed when the + # event fired post-apply). + pre_images[path] = content if content is not None else "" client._spec.snapshot = probing_snapshot await client.start_turn(TurnInput( @@ -125,17 +159,30 @@ async def probing_snapshot(sid: str, path: str, content: str | None) -> None: "Do nothing else." ), )) + probe_done = None async for event in client.receive_turn(): if isinstance(event, ev.TurnCompleted): + probe_done = event break final = target.read_text() if target.exists() else "" - pre = pre_images.get(str(target), "") - print(f"✓ fileChange probe: final={final.strip()!r} snapshot-time={pre.strip()!r}") - if "ORIGINAL" in pre: - print(" → item/started fires PRE-APPLY: snapshots are valid ✅") + pre = pre_images.get(str(target)) + print(f"✓ fileChange probe: final={final.strip()!r} " + f"snapshot-time={(pre or '').strip()!r}") + if probe_done is None or probe_done.status != "completed" or ( + "CHANGED" not in final + ): + print(" → probe INCONCLUSIVE (turn failed or no edit happened) — " + f"status={getattr(probe_done, 'status', '?')} " + f"error={getattr(probe_done, 'error', None)}") + elif pre and "ORIGINAL" in pre: + # The snapshot callback received the true BEFORE-content — + # via direct pre-apply capture or the reverse-diff + # reconstruction (backends/codex/diffs.py). Either way the + # diff panel gets a correct before/after pair. + print(" → snapshot holds the pre-image: diff panel correct ✅") else: - print(" → item/started fired POST-APPLY: enable the reverse-diff " - "fallback (docs/plans/codex-backend.md §13) ⚠️") + print(" → snapshot MISSED the pre-image (no diff on events?) — " + "investigate before relying on the diff panel ⚠️") await client.disconnect() print("✓ disconnect clean — smoke PASSED") diff --git a/tests/test_codex_appserver.py b/tests/test_codex_appserver.py index 40349b3..5506f52 100644 --- a/tests/test_codex_appserver.py +++ b/tests/test_codex_appserver.py @@ -35,7 +35,7 @@ def _config(tmp_path: Path, **codex_overrides) -> NerveConfig: "codex": { "bin_path": FAKE_BIN, "home_dir": str(tmp_path / "codex-home"), - "model": "gpt-5.6-codex", + "model": "gpt-5.6-sol", **codex_overrides, }, }) @@ -105,7 +105,7 @@ async def test_basic_turn_streams_and_completes(tmp_path, monkeypatch): assert "".join(texts) == "Hello " assert any(isinstance(e, ev.ThinkingDelta) for e in events) assert isinstance(events[0], ev.ModelObserved) - assert events[0].model == "gpt-5.6-codex" + assert events[0].model == "gpt-5.6-sol" done = events[-1] assert isinstance(done, ev.TurnCompleted) diff --git a/tests/test_codex_diffs.py b/tests/test_codex_diffs.py new file mode 100644 index 0000000..f77cbe0 --- /dev/null +++ b/tests/test_codex_diffs.py @@ -0,0 +1,55 @@ +"""reverse_apply_unified_diff — pre-image reconstruction unit tests.""" + +from __future__ import annotations + +import difflib + +import pytest + +from nerve.agent.backends.codex.diffs import reverse_apply_unified_diff + + +def _diff(before: str, after: str) -> str: + return "".join(difflib.unified_diff( + before.splitlines(keepends=True), + after.splitlines(keepends=True), + fromfile="a", tofile="b", + )) + + +@pytest.mark.parametrize("before,after", [ + ("ORIGINAL\n", "CHANGED\n"), + ("a\nb\nc\n", "a\nB\nc\n"), + ("a\nb\nc\n", "a\nb\nc\nd\n"), # pure addition + ("a\nb\nc\nd\n", "a\nd\n"), # deletion + ("one\ntwo\nthree\nfour\nfive\nsix\nseven\n", + "one\nTWO\nthree\nfour\nFIVE\nsix\nseven\nEIGHT\n"), # multi-hunk + ("", "hello\nworld\n"), # created content + ("x\n" * 50, "x\n" * 20 + "y\n" + "x\n" * 30), # mid-file insert +]) +def test_roundtrip(before: str, after: str): + diff = _diff(before, after) + assert reverse_apply_unified_diff(diff, after) == before + + +def test_wrong_after_text_fails_verification(): + diff = _diff("ORIGINAL\n", "CHANGED\n") + # Pre-apply timing: disk still holds the before-text — the reverse + # must FAIL (caller then uses the disk content directly). + assert reverse_apply_unified_diff(diff, "ORIGINAL\n") is None + assert reverse_apply_unified_diff(diff, "SOMETHING ELSE\n") is None + + +def test_garbage_and_empty_diffs_fail_closed(): + assert reverse_apply_unified_diff("", "x\n") is None + assert reverse_apply_unified_diff("not a diff at all", "x\n") is None + assert reverse_apply_unified_diff("--- a\n+++ b\n", "x\n") is None # headers only + + +def test_git_style_headers_are_tolerated(): + before, after = "a\nb\n", "a\nc\n" + diff = ( + "diff --git a/f b/f\nindex 000..111 100644\n" + + _diff(before, after) + ) + assert reverse_apply_unified_diff(diff, after) == before diff --git a/tests/test_codex_protocol.py b/tests/test_codex_protocol.py index 68ee99b..796cd72 100644 --- a/tests/test_codex_protocol.py +++ b/tests/test_codex_protocol.py @@ -78,7 +78,7 @@ async def test_command_exit_code_marks_error(tmp_path): await client._map_notification("item/started", {"item": { "id": "c9", "type": "commandExecution", "command": ["false"], }}) - events = client._map_item_completed({ + events = await client._map_item_completed({ "id": "c9", "type": "commandExecution", "command": ["false"], "aggregatedOutput": "", "exitCode": 3, }) diff --git a/tests/test_engine_backend_selection.py b/tests/test_engine_backend_selection.py index c38acf9..7a729fb 100644 --- a/tests/test_engine_backend_selection.py +++ b/tests/test_engine_backend_selection.py @@ -81,8 +81,8 @@ def test_claude_models_by_source(self, tmp_path, db): def test_codex_models_by_source(self, tmp_path, db): engine = _engine(tmp_path, db) codex = engine._backends["codex"] - assert codex.default_model("web") == "gpt-5.6-codex" - assert codex.default_model("cron") == "gpt-5.6-codex" # cron_model empty → model + assert codex.default_model("web") == "gpt-5.6-sol" + assert codex.default_model("cron") == "gpt-5.6-sol" # cron_model empty → model def test_codex_cron_model_override(self, tmp_path, db): cfg = NerveConfig.from_dict({ @@ -91,7 +91,7 @@ def test_codex_cron_model_override(self, tmp_path, db): }) engine = AgentEngine(cfg, db) assert engine._backends["codex"].default_model("cron") == "gpt-5.6-luna" - assert engine._backends["codex"].default_model("web") == "gpt-5.6-codex" + assert engine._backends["codex"].default_model("web") == "gpt-5.6-sol" class TestExcludedTools: From 32b98d4a2c593a4b38a241c0c1a5eda255b30483 Mon Sep 17 00:00:00 2001 From: pufit Date: Fri, 10 Jul 2026 18:07:55 -0400 Subject: [PATCH 3/8] Address adversarial review: claude error-path resume, schema-correct kind/approval shapes, CRLF diffs --- docs/plans/codex-backend.md | 43 +++++++++++++ nerve/agent/backends/__init__.py | 2 - nerve/agent/backends/base.py | 41 ++++++------ nerve/agent/backends/codex/appserver.py | 4 +- nerve/agent/backends/codex/backend.py | 81 ++++++++++++++++-------- nerve/agent/backends/codex/diffs.py | 11 ++-- nerve/agent/engine.py | 18 +++++- nerve/config.py | 29 +++++++-- tests/fixtures/fake_codex_appserver.py | 11 +++- tests/test_codex_appserver.py | 2 + tests/test_codex_diffs.py | 7 ++ tests/test_codex_protocol.py | 6 +- tests/test_engine_backend_selection.py | 66 +++++++++++++++++++ web/src/components/Chat/ApprovalCard.tsx | 10 ++- 14 files changed, 265 insertions(+), 66 deletions(-) diff --git a/docs/plans/codex-backend.md b/docs/plans/codex-backend.md index f7028b1..37ec19c 100644 --- a/docs/plans/codex-backend.md +++ b/docs/plans/codex-backend.md @@ -626,6 +626,15 @@ code where the fallback goes). ## 14. Out of scope (v1) — explicit non-parity list - Langfuse tracing for codex turns (usage rows + audit only). +- **Permission grants** (`item/permissions/requestApproval`): the response + type requires a constructed `GrantedPermissionProfile` with no decline + variant — nerve answers with a JSON-RPC error, which codex treats as + not-granted and continues sandboxed (logged). Command and file-change + approvals ARE supported. +- Concurrent approval cards: the web UI holds one pending interaction at + a time (pre-existing store design); a second simultaneous approval + (possible under `untrusted`) waits server-side until the first resolves + or times out. - Claude Code plugin MCPs on codex (plugin lifecycle is claude-CLI-owned). - PDF/document inputs on codex (inline note to the model instead of silent drop). - Dynamic client-registered tools over app-server; `turn/steer`; `thread/rollback`; @@ -720,3 +729,37 @@ real app-server + OpenAI API key):** - Not exercised live: `mcp_servers.nerve` override effectiveness (smoke runs without a gateway; asserted against the fake's argv mirror — the first real nerve-session turn will confirm tool calls land). + +**Adversarial review round 2 (2026-07-10, post-implementation subagent +review of the full branch — 3 MAJOR / 9 MINOR / 4 NIT, all addressed or +descoped explicitly):** + +- MAJOR: the Claude generic-error path lost the early-captured resume id + (crashed turns restarted conversations). Fixed: the exception handler + now pulls `client.native_session_id` (like the cancel path) — EXCEPT + for poisoned contexts, which must start fresh (the pre-refactor code + re-persisted the poisoned id there; deliberately not preserved). +- MAJOR: `FileUpdateChange.kind` is a tagged object (`{"type": "add"}`) + in the v2 schema, not a string. Fixed via `_change_kind` normalization; + the fake app-server + tests now emit the schema shape; ApprovalCard + renders both shapes. +- MAJOR: `item/permissions/requestApproval` reply shape was invalid — + descoped to a JSON-RPC-error denial (see §14). +- MINOR fixes: `resume_dropped` no longer un-done by `mark_active` + (stale local id dropped; engine-level regression test added); + elicitation → `{"action": "decline"}` and requestUserInput → + `{"answers": {}}` (schema-correct); legacy execCommandApproval/ + applyPatchApproval aliases removed (nerve never opts into the legacy + API); late `turn/completed` now scoped by turn id; CRLF files + round-trip through the reverse-diff; malformed INACTIVE codex config + can no longer brick startup (lenient coercion + warnings); + `codex.turn_idle_timeout_seconds` actually wired; `model/rerouted` + reads `toModel` (the real field); realtime opt-out method names + corrected; `_last_error` reset per turn; MCP server names validated as + TOML key segments. +- Plan corrections (was overclaiming): ollama+codex is a load-time + WARNING, not an error (the hazard is per-model, guarded by the claude + path's ollama routing); `codex/protocol.py` was folded into + `backend.py`; `tests/test_backend_events.py` coverage lives in + test_engine.py/test_autonomous_turns.py; v038 has no dedicated + migration test (covered by the schema-version suite). diff --git a/nerve/agent/backends/__init__.py b/nerve/agent/backends/__init__.py index 79977ac..d821ac1 100644 --- a/nerve/agent/backends/__init__.py +++ b/nerve/agent/backends/__init__.py @@ -16,7 +16,6 @@ AgentClient, BackendCapabilities, BackendError, - ResumeDroppedError, SessionSpec, TransportDiedError, TurnInput, @@ -85,7 +84,6 @@ def build_backends(deps: BackendDeps) -> dict[str, AgentBackend]: "BackendError", "ModelObserved", "NormalizedUsage", - "ResumeDroppedError", "SessionSpec", "SubagentStarted", "SystemEvent", diff --git a/nerve/agent/backends/base.py b/nerve/agent/backends/base.py index 8798fbe..126a347 100644 --- a/nerve/agent/backends/base.py +++ b/nerve/agent/backends/base.py @@ -36,20 +36,6 @@ class TransportDiedError(BackendError): """The backend subprocess/stream died mid-operation.""" -class ResumeDroppedError(BackendError): - """The stored native session id could not be resumed. - - Raised (or reported) by ``create_client`` after it has already - recovered by starting a fresh native session — the engine must clear - the persisted ``sdk_session_id``. Carries the live client so no work - is lost. - """ - - def __init__(self, message: str, client: "AgentClient | None" = None): - super().__init__(message) - self.client = client - - @dataclass class TurnInput: """One user turn, engine-normalized. @@ -133,12 +119,21 @@ def is_alive(self) -> bool: ... # -- autonomous/idle stream (only when supports_idle_stream) ------- # - def try_receive_idle_event(self) -> AgentEvent | None: - """Non-parking probe of the between-turns stream (None = empty).""" + def try_receive_idle_events(self) -> "list[AgentEvent] | None": + """Non-parking probe of the between-turns stream. + + One native message's translated events, ``[]`` for skip-and- + continue payloads, ``None`` when nothing is buffered or the + stream is closed. + """ ... - async def receive_idle_event(self, timeout: float) -> AgentEvent | None: - """Park up to ``timeout`` seconds for a between-turns event.""" + async def receive_idle_events( + self, timeout: "float | None", + ) -> "list[AgentEvent] | None": + """Park up to ``timeout`` seconds for the next between-turns + message; raises ``asyncio.TimeoutError`` on expiry, returns + ``None`` when the stream ended.""" ... def buffer_used(self) -> int: @@ -157,9 +152,13 @@ def default_model(self, source: str) -> str: ... async def create_client(self, spec: SessionSpec) -> AgentClient: - """Build and connect a client. May raise :class:`ResumeDroppedError` - (carrying the recovered client) when a stale resume target had to - be discarded.""" + """Build and connect a client. + + When a stale resume target had to be discarded (the backend + recovered by starting a fresh native session), the returned + client exposes ``resume_dropped == True`` and the engine clears + the persisted ``sdk_session_id``. + """ ... def validate_resume_target(self, native_id: str, cwd: str) -> bool: diff --git a/nerve/agent/backends/codex/appserver.py b/nerve/agent/backends/codex/appserver.py index b39fe5c..8f24c73 100644 --- a/nerve/agent/backends/codex/appserver.py +++ b/nerve/agent/backends/codex/appserver.py @@ -41,8 +41,8 @@ "thread/realtime/itemAdded", "thread/realtime/outputAudio/delta", "thread/realtime/sdp", - "thread/realtime/transcriptDelta", - "thread/realtime/transcriptDone", + "thread/realtime/transcript/delta", + "thread/realtime/transcript/done", "fuzzyFileSearch/sessionUpdated", "fuzzyFileSearch/sessionCompleted", ] diff --git a/nerve/agent/backends/codex/backend.py b/nerve/agent/backends/codex/backend.py index fa409dd..6c7d20d 100644 --- a/nerve/agent/backends/codex/backend.py +++ b/nerve/agent/backends/codex/backend.py @@ -23,6 +23,7 @@ import json import logging import os +import re from pathlib import Path from typing import Any, AsyncIterator @@ -189,9 +190,15 @@ def build_config_overrides(self, spec: SessionSpec) -> list[str]: overrides.append(f"{key}={value}") return overrides - @staticmethod - def _translate_mcp_server(srv: Any) -> list[str]: + _TOML_KEY_RE = re.compile(r"^[A-Za-z0-9_-]+$") + + @classmethod + def _translate_mcp_server(cls, srv: Any) -> list[str]: """Translate one nerve ``McpServerConfig`` into codex overrides.""" + if not cls._TOML_KEY_RE.match(str(srv.name)): + raise ValueError( + f"server name {srv.name!r} is not a bare TOML key segment" + ) base = f"mcp_servers.{srv.name}" out: list[str] = [] command = getattr(srv, "command", None) @@ -204,6 +211,8 @@ def _translate_mcp_server(srv: Any) -> list[str]: out.append(f"{base}.args=[{arr}]") env = getattr(srv, "env", None) or {} for k, v in env.items(): + if not cls._TOML_KEY_RE.match(str(k)): + raise ValueError(f"env key {k!r} is not a bare TOML key") out.append(f"{base}.env.{k}={_toml_str(str(v))}") elif url: out.append(f"{base}.url={_toml_str(url)}") @@ -367,6 +376,7 @@ async def start_turn(self, turn: TurnInput) -> None: if not self._thread_id: raise BackendError("codex client has no thread") self._turn_usage = None + self._last_error = None self._items.clear() # Drain notifications that straggled in after the previous turn # (late tokenUsage updates etc.) so they can't bleed into this one. @@ -449,7 +459,10 @@ async def receive_turn(self) -> AsyncIterator[ev.AgentEvent]: regardless of status (completed / interrupted / failed) so the /stop flow's graceful wait works. """ - idle_timeout = self._spec.idle_timeout + idle_timeout = ( + float(self._backend.codex.turn_idle_timeout_seconds) + or self._spec.idle_timeout + ) # The thread model is serving this turn — surface it for # serving-model tracking (parity with AssistantMessage.model). yield ev.ModelObserved(model=self.model) @@ -527,10 +540,7 @@ async def _map_notification( self._context_window = window elif method == "model/rerouted": - new_model = ( - params.get("model") or params.get("toModel") - or params.get("to") - ) + new_model = params.get("toModel") or params.get("model") if new_model: self.model = str(new_model) out.append(ev.ModelObserved(model=self.model)) @@ -539,7 +549,13 @@ async def _map_notification( out.append(ev.SystemEvent(subtype="codex_plan", data=params)) elif method == "turn/completed": - out.append(self._map_turn_completed(params)) + turn_id = ((params.get("turn") or {}).get("id")) + if turn_id and self._turn_id and str(turn_id) != self._turn_id: + logger.debug( + "codex: dropping stale turn/completed for %s", turn_id, + ) + else: + out.append(self._map_turn_completed(params)) elif method == "error": message = self._error_message(params) @@ -621,7 +637,7 @@ async def _map_item_started(self, item: dict) -> list[ev.AgentEvent]: name="Edit", input={ "file_path": path, - "kind": change.get("kind"), + "kind": self._change_kind(change) or None, }, )) elif item_type == "mcpToolCall": @@ -636,7 +652,7 @@ async def _map_item_started(self, item: dict) -> list[ev.AgentEvent]: name="WebSearch", input={"query": item.get("query", "")}, )) - elif item_type in ("plan", "planUpdate", "todoList"): + elif "plan" in item_type.lower() or "todo" in item_type.lower(): out.append(ev.SystemEvent(subtype="codex_plan", data=item)) # agentMessage/reasoning items stream via their delta # notifications; nothing to emit at start. @@ -673,7 +689,7 @@ async def _map_item_completed(self, item: dict) -> list[ev.AgentEvent]: diff = str(change.get("diff") or "") out.append(ev.ToolResult( tool_use_id=self._change_id(item_id, n), - content=diff or f"({change.get('kind') or 'change'} applied)", + content=diff or f"({self._change_kind(change) or 'change'} applied)", is_error=failed, )) elif item_type == "mcpToolCall": @@ -716,7 +732,7 @@ async def _snapshot_pre_image(self, path: str, change: dict) -> None: return # already captured this session from nerve.agent.interactive import _read_file_safe - kind = str(change.get("kind") or "").lower() + kind = self._change_kind(change).lower() content: str | None if kind == "add": content = None # new file — no pre-image @@ -796,6 +812,16 @@ def _command_str(item: dict) -> str: return " ".join(str(part) for part in command) return str(command or "") + @staticmethod + def _change_kind(change: dict) -> str: + """``FileUpdateChange.kind`` is a tagged object ``{"type": "add" | + "delete" | "update"}`` in the v2 schema — normalize to the string + (tolerating legacy/plain-string shapes).""" + kind = change.get("kind") + if isinstance(kind, dict): + kind = kind.get("type") + return str(kind or "") + @staticmethod def _changes(item: dict) -> list[dict]: changes = item.get("changes") @@ -831,32 +857,37 @@ def _mcp_tool_input(item: dict) -> dict: # -- approvals (server-initiated requests) ---------------------------- # async def _handle_server_request(self, method: str, params: dict) -> dict: - if method in ( - "item/commandExecution/requestApproval", - "execCommandApproval", - ): + if method == "item/commandExecution/requestApproval": return await self._approval( "command_approval", self._approval_payload(params), ) - if method in ("item/fileChange/requestApproval", "applyPatchApproval"): + if method == "item/fileChange/requestApproval": return await self._approval( "file_approval", self._approval_payload(params), ) if method == "item/permissions/requestApproval": - return await self._approval( - "permission_approval", self._approval_payload(params), + # The response type requires a constructed GrantedPermissionProfile + # — there is no decline variant to express. Unsupported in v1 + # (docs plan §14): raising here makes the transport answer with + # a JSON-RPC error, which codex treats as not-granted and + # continues sandboxed. Surfaced to the log so the operator + # knows a grant was requested. + logger.warning( + "codex requested a permission grant — unsupported (v1), " + "denied via JSON-RPC error: %s", str(params)[:200], ) + raise BackendError("permission grants are not supported by nerve v1") if method == "item/tool/requestUserInput": - # v1: nerve's ask_user covers interactive questions; decline - # tool-level input requests explicitly (docs plan §7). + # v1: nerve's ask_user covers interactive questions; answer + # the (experimental) request with an empty answer set. logger.warning( - "codex requested tool user input — declining (unsupported): %s", - str(params)[:200], + "codex requested tool user input — returning no answers " + "(unsupported): %s", str(params)[:200], ) - return {} + return {"answers": {}} if method == "mcpServer/elicitation/request": logger.warning("codex MCP elicitation declined (unsupported)") - return {} + return {"action": "decline"} if method.endswith("requestApproval") or method.endswith("Approval"): logger.warning( diff --git a/nerve/agent/backends/codex/diffs.py b/nerve/agent/backends/codex/diffs.py index b141946..efe0580 100644 --- a/nerve/agent/backends/codex/diffs.py +++ b/nerve/agent/backends/codex/diffs.py @@ -39,8 +39,10 @@ def reverse_apply_unified_diff(diff: str, after_text: str) -> str | None: return None keepends = after_text.splitlines(keepends=True) - # Track whether the after-text ends without a newline so the - # reconstruction can preserve the analogous property. + # Removed lines are re-inserted with the file's dominant terminator so + # CRLF files round-trip (diff.splitlines() strips the \r from hunk + # body lines; comparisons go through _line(), which strips both). + eol = "\r\n" if "\r\n" in after_text else "\n" before_parts: list[str] = [] pos = 0 # cursor into keepends (0-based line index of after_text) @@ -96,8 +98,9 @@ def reverse_apply_unified_diff(diff: str, after_text: str) -> str | None: return None pos += 1 elif tag == "-": - # Absent in after, present in before — re-insert. - before_parts.append(content + "\n") + # Absent in after, present in before — re-insert with the + # file's dominant line terminator. + before_parts.append(content.rstrip("\r") + eol) else: # Not a hunk body line (e.g. the next file's header in a # concatenated diff) — end of this hunk. diff --git a/nerve/agent/engine.py b/nerve/agent/engine.py index f5c4620..853af53 100644 --- a/nerve/agent/engine.py +++ b/nerve/agent/engine.py @@ -1145,11 +1145,13 @@ async def _record_wakeup_cb(sid: str, tool_input: dict) -> Any: client = await backend.create_client(spec) if getattr(client, "resume_dropped", False): # The backend had to discard the stale native id (codex - # resume-miss recovery) — clear the persisted column; the - # fresh id is re-persisted at turn end. + # resume-miss recovery) — clear the persisted column AND + # the local variable (mark_active below would otherwise + # re-persist the stale id); the fresh id lands at turn end. await self.db.update_session_fields( session_id, {"sdk_session_id": None}, ) + sdk_resume_id = None self.sessions.set_client(session_id, client) self._session_backends[session_id] = backend.name @@ -2385,6 +2387,18 @@ async def _run_inner( "Could not process image" in err_str or "Could not process document" in err_str ) + # Preserve resumability on crashed turns (parity with the old + # any-message early capture): the terminal event never arrived, + # so pull the native id off the live client. _finalize_turn's + # mark_active then restores it after mark_error's clear. + # Poisoned contexts are the exception — they MUST start fresh + # (the old code re-persisted the poisoned id here; fixed). + if not is_poisoned and not st.sdk_session_id: + _live = self.sessions.get_client(session_id) + if _live is not None: + with contextlib.suppress(Exception): + st.sdk_session_id = _live.native_session_id + if is_poisoned: logger.warning( "Poisoned context detected for session %s: %s — " diff --git a/nerve/config.py b/nerve/config.py index 431a385..3e985f9 100644 --- a/nerve/config.py +++ b/nerve/config.py @@ -150,7 +150,7 @@ class AgentConfig: # Effort for cron- and hook-sourced turns (sensing / triage work). These # fire far more often than interactive sessions and rarely need Opus-tier # deliberation, so they default lower than `effort` above to cut token - # spend. Applied in engine._build_options when source is "cron" or "hook"; + # spend. Applied by the claude backend when source is "cron" or "hook"; # interactive sources (web, telegram, wakeup) keep the full `effort`. cron_effort: str = "medium" # max, xhigh, high, medium, low context_1m: bool = True # Enable 1M context window beta @@ -908,6 +908,16 @@ def from_dict(cls, d: dict) -> ExternalAgentsConfig: ) +def _lenient_int(value: Any, default: int) -> int: + """Best-effort int coercion — malformed inactive config must not + brick startup (validate() reports problems when the section is live).""" + try: + return int(value) if value is not None else default + except (TypeError, ValueError): + logger.warning("Ignoring non-integer config value %r", value) + return default + + _CODEX_APPROVAL_POLICIES = ("never", "on-request", "untrusted") _CODEX_SANDBOX_MODES = ("read-only", "workspace-write", "danger-full-access") @@ -959,10 +969,19 @@ def from_dict(cls, d: dict) -> "CodexConfig": raw_pricing = d.get("pricing") or {} if isinstance(raw_pricing, dict): for model_key, prices in raw_pricing.items(): - if isinstance(prices, dict): + if not isinstance(prices, dict): + continue + try: pricing[str(model_key)] = { str(k): float(v) for k, v in prices.items() } + except (TypeError, ValueError): + # Lenient here so a malformed INACTIVE codex section + # can't brick startup; the entry is dropped (cost + # records None) and flagged. + logger.warning( + "Ignoring malformed codex.pricing entry %r", model_key, + ) effort_map = { "max": "xhigh", "xhigh": "xhigh", "high": "high", "medium": "medium", "low": "low", @@ -982,8 +1001,10 @@ def from_dict(cls, d: dict) -> "CodexConfig": approval_policy=str(d.get("approval_policy", "never")), effort_map=effort_map, web_search=bool(d.get("web_search", True)), - tool_timeout_sec=int(d.get("tool_timeout_sec", 3600)), - turn_idle_timeout_seconds=int(d.get("turn_idle_timeout_seconds", 0)), + tool_timeout_sec=_lenient_int(d.get("tool_timeout_sec"), 3600), + turn_idle_timeout_seconds=_lenient_int( + d.get("turn_idle_timeout_seconds"), 0, + ), pricing=pricing, extra_config=dict(d.get("extra_config") or {}), ) diff --git a/tests/fixtures/fake_codex_appserver.py b/tests/fixtures/fake_codex_appserver.py index 465d650..dfc0730 100755 --- a/tests/fixtures/fake_codex_appserver.py +++ b/tests/fixtures/fake_codex_appserver.py @@ -154,9 +154,12 @@ def run_turn(thread_id: str, turn_id: str) -> None: "aggregatedOutput": "hi\n", "exitCode": 0, "status": "completed"}, }) + # kind is a TAGGED OBJECT in the v2 schema (PatchChangeKind). changes = [ - {"path": "/tmp/fake_a.txt", "kind": "update", "diff": "-a\n+b\n"}, - {"path": "/tmp/fake_b.txt", "kind": "add", "diff": "+new\n"}, + {"path": "/tmp/fake_a.txt", "kind": {"type": "update"}, + "diff": "-a\n+b\n"}, + {"path": "/tmp/fake_b.txt", "kind": {"type": "add"}, + "diff": "+new\n"}, ] notify("item/started", { "threadId": thread_id, "turnId": turn_id, @@ -177,7 +180,9 @@ def run_turn(thread_id: str, turn_id: str) -> None: notify("item/completed", { "threadId": thread_id, "turnId": turn_id, "item": {"id": "t1", "type": "mcpToolCall", "server": "nerve", - "tool": "memorize", "result": "Memorized: x", + "tool": "memorize", + "result": {"content": [{"type": "text", + "text": "Memorized: x"}]}, "status": "completed"}, }) diff --git a/tests/test_codex_appserver.py b/tests/test_codex_appserver.py index 5506f52..cef99b8 100644 --- a/tests/test_codex_appserver.py +++ b/tests/test_codex_appserver.py @@ -184,6 +184,8 @@ async def snapshot(sid, path, content): assert {u.input["file_path"] for u in edits} == { "/tmp/fake_a.txt", "/tmp/fake_b.txt", } + # PatchChangeKind objects normalize to plain strings. + assert {u.input["kind"] for u in edits} == {"update", "add"} edit_ids = {u.tool_use_id for u in edits} edit_results = [r for r in results if r.tool_use_id in edit_ids] assert len(edit_results) == 2 diff --git a/tests/test_codex_diffs.py b/tests/test_codex_diffs.py index f77cbe0..3bfe33e 100644 --- a/tests/test_codex_diffs.py +++ b/tests/test_codex_diffs.py @@ -53,3 +53,10 @@ def test_git_style_headers_are_tolerated(): + _diff(before, after) ) assert reverse_apply_unified_diff(diff, after) == before + + +def test_crlf_files_round_trip_with_correct_terminators(): + before = "aaa\r\nbbb\r\nccc\r\n" + after = "aaa\r\nBBB\r\nccc\r\n" + diff = _diff(before, after) + assert reverse_apply_unified_diff(diff, after) == before diff --git a/tests/test_codex_protocol.py b/tests/test_codex_protocol.py index 796cd72..ec5ff02 100644 --- a/tests/test_codex_protocol.py +++ b/tests/test_codex_protocol.py @@ -66,7 +66,11 @@ async def test_stale_turn_usage_is_scoped(tmp_path): @pytest.mark.asyncio async def test_model_rerouted_updates_serving_model(tmp_path): client = _client(tmp_path) - events = await client._map_notification("model/rerouted", {"model": "gpt-5.6-terra"}) + # Schema shape: {fromModel, toModel, reason, threadId, turnId} + events = await client._map_notification("model/rerouted", { + "fromModel": "gpt-5.6-sol", "toModel": "gpt-5.6-terra", + "threadId": "t", "turnId": "x", "reason": "capacity", + }) assert events == [ev.ModelObserved(model="gpt-5.6-terra")] done = client._map_turn_completed({"turn": {"id": "x", "status": "completed"}}) assert done.model == "gpt-5.6-terra" diff --git a/tests/test_engine_backend_selection.py b/tests/test_engine_backend_selection.py index 7a729fb..283057a 100644 --- a/tests/test_engine_backend_selection.py +++ b/tests/test_engine_backend_selection.py @@ -184,3 +184,69 @@ async def test_records_wakeup_for_engine_sessions(self, tmp_path, db): # missing prompt → error, nothing scheduled bad = await schedule_wakeup_handler(ctx, {"delaySeconds": 60, "prompt": " "}) assert bad.is_error + + +class TestResumeDroppedEnginePath: + @pytest.mark.asyncio + async def test_stale_native_id_is_cleared_not_repersisted(self, tmp_path, db): + """When the backend reports resume_dropped, the engine must clear + sessions.sdk_session_id AND not let mark_active re-persist the + stale id (review finding: the local variable also has to be + dropped).""" + from types import SimpleNamespace + + from nerve.agent.backends.base import BackendCapabilities + + engine = _engine(tmp_path, db) + await db.create_session("s-drop", source="web") + await db.update_session_fields("s-drop", { + "sdk_session_id": "stale-thread-1", "backend": "codex", + }) + + class StubClient: + resume_dropped = True + native_session_id = "fresh-thread-9" + model = "gpt-5.6-sol" + + def is_alive(self): + return True + + async def disconnect(self): + pass + + class StubBackend: + name = "codex" + capabilities = BackendCapabilities( + cost_is_cumulative=False, + supports_idle_stream=False, + supports_cache_ttl=False, + interactive_builtins=False, + reports_context_window=True, + ) + + def default_model(self, source): + return "gpt-5.6-sol" + + def excluded_tools(self): + return set() + + def validate_resume_target(self, native_id, cwd): + return True + + async def create_client(self, spec): + # The backend already recovered with a fresh thread — + # spec still carried the stale id. + assert spec.resume_native_id == "stale-thread-1" + return StubClient() + + engine._backends["codex"] = StubBackend() + client = await engine._get_or_create_client("s-drop", "web", None) + assert client.resume_dropped is True + + session = await db.get_session("s-drop") + # The stale id must be GONE (mark_active must not re-persist it); + # the fresh id lands at turn end via TurnCompleted. + assert session.get("sdk_session_id") in (None, ""), session.get( + "sdk_session_id", + ) + assert session.get("backend") == "codex" diff --git a/web/src/components/Chat/ApprovalCard.tsx b/web/src/components/Chat/ApprovalCard.tsx index 080dd4b..1af031b 100644 --- a/web/src/components/Chat/ApprovalCard.tsx +++ b/web/src/components/Chat/ApprovalCard.tsx @@ -16,7 +16,13 @@ const APPROVAL_TYPES = new Set(['command_approval', 'file_approval', 'permission interface ChangeEntry { path?: string; - kind?: string; + // PatchChangeKind: tagged object in the v2 protocol; legacy strings tolerated. + kind?: string | { type?: string }; +} + +function kindLabel(kind: ChangeEntry['kind']): string { + if (kind && typeof kind === 'object') return kind.type || 'edit'; + return kind || 'edit'; } export function ApprovalCard() { @@ -71,7 +77,7 @@ export function ApprovalCard() {
    {changes.map((c, i) => (
  • - {c.kind || 'edit'} + {kindLabel(c.kind)} {c.path}
  • ))} From 1a7cb617faaecf92301b04cb6b13e9fb9a5ff39d Mon Sep 17 00:00:00 2001 From: pufit Date: Fri, 10 Jul 2026 18:28:51 -0400 Subject: [PATCH 4/8] Add Claude/Codex backend selector to the new-chat composer --- docs/plans/codex-backend.md | 14 ++++ nerve/gateway/routes/models.py | 12 ++++ nerve/gateway/routes/sessions.py | 16 ++++- tests/test_engine_backend_selection.py | 45 ++++++++++++ web/src/api/client.ts | 8 ++- web/src/components/Chat/BackendSelector.tsx | 76 +++++++++++++++++++++ web/src/components/Chat/ChatInput.tsx | 21 +++++- web/src/stores/chatStore.ts | 26 ++++++- 8 files changed, 211 insertions(+), 7 deletions(-) create mode 100644 web/src/components/Chat/BackendSelector.tsx diff --git a/docs/plans/codex-backend.md b/docs/plans/codex-backend.md index 37ec19c..194dc88 100644 --- a/docs/plans/codex-backend.md +++ b/docs/plans/codex-backend.md @@ -653,6 +653,20 @@ code where the fallback goes). 4. Watch: usage rows (`raw`), cost attribution, audit trail, approval UX. 5. Full flip (`agent.backend: codex`) = config edit + `nerve restart` — Artem's call. +## 15b. New-chat backend selector (UI, added on request) + +The composer shows a segmented **Claude / Codex** control on new (virtual) +chats: Claude in the brand-orange tint, Codex in teal, tooltips carrying +each backend's default model. The choice binds at server-side session +creation (`POST /api/sessions {backend}` → metadata `backend_override`, +validated against the engine's backend registry) and the control +disappears once the conversation starts — the header's existing model +badge then shows what the session runs on. Codex-selected chats hide the +Ollama model picker (its entries can't be served by codex). `GET +/api/models` now advertises `backends: {default, options:[{id,label, +model}]}` for the selector. Visually verified against a scratch gateway +(screenshots: codex-selector-3/4.png in the workspace). + ## 16. Review log - v1 → v2 (2026-07-10): adversarial subagent review (agent a4093b396028e149b) found 4 diff --git a/nerve/gateway/routes/models.py b/nerve/gateway/routes/models.py index dcc9b92..066d828 100644 --- a/nerve/gateway/routes/models.py +++ b/nerve/gateway/routes/models.py @@ -43,6 +43,17 @@ async def list_models(user: dict = Depends(require_auth)): config = get_config() default_model = config.agent.model + # Agent backends for the new-chat selector. Both are always offered — + # construction is unconditional server-side; a codex pick without + # auth configured surfaces a clear, actionable error on first message. + backends = { + "default": config.agent.backend, + "options": [ + {"id": "claude", "label": "Claude", "model": config.agent.model}, + {"id": "codex", "label": "Codex", "model": config.codex.model}, + ], + } + models: list[dict[str, str]] = [ {"id": default_model, "provider": "anthropic"}, ] @@ -56,6 +67,7 @@ async def list_models(user: dict = Depends(require_auth)): return { "default": default_model, + "backends": backends, "models": models, "ollama": { "enabled": config.ollama.enabled, diff --git a/nerve/gateway/routes/sessions.py b/nerve/gateway/routes/sessions.py index 327c07e..42168bc 100644 --- a/nerve/gateway/routes/sessions.py +++ b/nerve/gateway/routes/sessions.py @@ -57,6 +57,10 @@ class MessageResponse(BaseModel): class SessionCreateRequest(BaseModel): title: str | None = None source: str = "web" + # Agent backend for this session ("claude" | "codex"). Stored as + # metadata backend_override; the engine stamps the sticky + # sessions.backend column at first client build (docs plan §3). + backend: str | None = None class ForkRequest(BaseModel): @@ -97,9 +101,19 @@ async def search_sessions(q: str, user: dict = Depends(require_auth)): @router.post("/api/sessions") async def create_session(req: SessionCreateRequest, user: dict = Depends(require_auth)): deps = get_deps() + metadata = None + if req.backend: + backend = req.backend.strip().lower() + if backend not in deps.engine._backends: + raise HTTPException( + status_code=400, + detail=f"Unknown backend {backend!r} " + f"(known: {sorted(deps.engine._backends)})", + ) + metadata = {"backend_override": backend} session_id = str(uuid.uuid4())[:8] session = await deps.engine.sessions.get_or_create( - session_id, title=req.title, source=req.source + session_id, title=req.title, source=req.source, metadata=metadata, ) return session diff --git a/tests/test_engine_backend_selection.py b/tests/test_engine_backend_selection.py index 283057a..56c8c59 100644 --- a/tests/test_engine_backend_selection.py +++ b/tests/test_engine_backend_selection.py @@ -250,3 +250,48 @@ async def create_client(self, spec): "sdk_session_id", ) assert session.get("backend") == "codex" + + +class TestCreateSessionRoute: + """POST /api/sessions with the new-chat backend selector's param.""" + + @pytest.mark.asyncio + async def test_backend_param_binds_override(self, tmp_path, db, monkeypatch): + import json + from types import SimpleNamespace + + from fastapi import HTTPException + + from nerve.gateway.routes import sessions as routes + + engine = _engine(tmp_path, db) + monkeypatch.setattr( + routes, "get_deps", + lambda: SimpleNamespace(engine=engine, db=db), + ) + + created = await routes.create_session( + routes.SessionCreateRequest(backend="codex"), user={"sub": "user"}, + ) + row = await db.get_session(created["id"]) + meta = json.loads(row.get("metadata") or "{}") + assert meta["backend_override"] == "codex" + # ...and the engine's sticky resolution honors it for the new session. + assert engine._backend_for(row, "web").name == "codex" + + # Omitted backend → no override, config default applies. + plain = await routes.create_session( + routes.SessionCreateRequest(), user={"sub": "user"}, + ) + plain_row = await db.get_session(plain["id"]) + plain_meta = json.loads(plain_row.get("metadata") or "{}") + assert "backend_override" not in plain_meta + assert engine._backend_for(plain_row, "web").name == "claude" + + # Unknown backend → 400, nothing created. + with pytest.raises(HTTPException) as excinfo: + await routes.create_session( + routes.SessionCreateRequest(backend="geminy"), + user={"sub": "user"}, + ) + assert excinfo.value.status_code == 400 diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 29302ab..52fb91b 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -68,6 +68,10 @@ export const api = { getModels: () => request<{ default: string; + backends?: { + default: string; + options: { id: string; label: string; model: string }[]; + }; models: { id: string; provider: string }[]; ollama: { enabled: boolean; routable: boolean; available: boolean }; }>('/models'), @@ -77,10 +81,10 @@ export const api = { searchSessions: (q: string) => request<{ sessions: any[] }>(`/sessions/search?q=${encodeURIComponent(q)}`), getSession: (id: string) => request(`/sessions/${id}`), - createSession: (title?: string) => + createSession: (title?: string, backend?: string | null) => request('/sessions', { method: 'POST', - body: JSON.stringify({ title }), + body: JSON.stringify({ title, ...(backend ? { backend } : {}) }), }), deleteSession: (id: string) => request(`/sessions/${id}`, { method: 'DELETE' }), diff --git a/web/src/components/Chat/BackendSelector.tsx b/web/src/components/Chat/BackendSelector.tsx new file mode 100644 index 0000000..a6207a3 --- /dev/null +++ b/web/src/components/Chat/BackendSelector.tsx @@ -0,0 +1,76 @@ +import { Sparkle, SquareTerminal } from 'lucide-react'; +import { useChatStore } from '../../stores/chatStore'; + +/** + * Agent-backend selector for NEW chats: Claude (Claude Agent SDK) vs + * Codex (OpenAI app-server, GPT-5.6). + * + * The choice binds when the session is created server-side (first + * message / first upload) and is sticky for the session's lifetime + * (`sessions.backend`), so the control only renders while the chat is + * still virtual — after that the header's model badge shows what the + * session runs on. + */ + +const STYLES: Record = { + claude: { + icon: Sparkle, + active: 'text-hue-orange bg-orange-500/10 shadow-[inset_0_0_0_1px_rgba(249,115,22,0.3)]', + dot: 'bg-hue-orange', + }, + codex: { + icon: SquareTerminal, + active: 'text-hue-teal bg-teal-400/10 shadow-[inset_0_0_0_1px_rgba(45,212,191,0.3)]', + dot: 'bg-hue-teal', + }, +}; + +export function BackendSelector({ disabled }: { disabled?: boolean }) { + const backendOptions = useChatStore(s => s.backendOptions); + const backendDefault = useChatStore(s => s.backendDefault); + const newChatBackend = useChatStore(s => s.newChatBackend); + const setNewChatBackend = useChatStore(s => s.setNewChatBackend); + + if (backendOptions.length < 2) return null; + + const selected = newChatBackend ?? backendDefault ?? backendOptions[0].id; + + return ( +
    + {backendOptions.map((opt) => { + const style = STYLES[opt.id] ?? STYLES.claude; + const Icon = style.icon; + const isActive = selected === opt.id; + return ( + + ); + })} +
    + ); +} diff --git a/web/src/components/Chat/ChatInput.tsx b/web/src/components/Chat/ChatInput.tsx index 31c20ff..1d5427b 100644 --- a/web/src/components/Chat/ChatInput.tsx +++ b/web/src/components/Chat/ChatInput.tsx @@ -5,6 +5,7 @@ import type { QuoteAction, QuoteEntry } from '../../stores/chatStore'; import { api } from '../../api/client'; import { randomUUID } from '../../utils/uuid'; import { PromptRewriteCard } from './PromptRewriteCard'; +import { BackendSelector } from './BackendSelector'; const ACTION_CONFIG: Record = { add: { icon: Plus, label: 'Add', color: 'var(--theme-accent)', placeholder: 'Instructions...' }, @@ -60,6 +61,14 @@ export function ChatInput({ onSend, onStop, isStreaming, disabled }: { const activeSession = useChatStore(s => s.activeSession); const ensureRealSession = useChatStore(s => s.ensureRealSession); const isNewChat = useChatStore(s => s.messages.length === 0); + // Backend selector renders only while the chat is virtual (unsent): + // the choice binds at server-side session creation and is sticky. + const isVirtualChat = useChatStore( + s => s.virtualSession !== null && s.virtualSession.id === s.activeSession, + ); + const newChatBackend = useChatStore(s => s.newChatBackend); + const backendDefault = useChatStore(s => s.backendDefault); + const chosenBackend = newChatBackend ?? backendDefault; // ── Model picker ── const availableModels = useChatStore(s => s.availableModels); @@ -455,10 +464,18 @@ export function ChatInput({ onSend, onStop, isStreaming, disabled }: { )} + {/* Agent backend selector — Claude vs Codex, new chats only. + Binds at session creation; sticky afterwards (the header's + model badge shows what a running session uses). */} + {isVirtualChat && ( + + )} {/* Model picker — only when more than one model is offered (local Ollama models configured + available). Hidden otherwise so the - composer is unchanged for the default single-model setup. */} - {availableModels.length > 1 && ( + composer is unchanged for the default single-model setup. + Codex chats hide it: its entries are Anthropic/Ollama models, + which the codex backend cannot serve (codex.model applies). */} + {availableModels.length > 1 && !(isVirtualChat && chosenBackend === 'codex') && ( setSelectedModel(e.target.value === modelsDefault ? null : e.target.value)} + onChange={(e) => setSelectedModel(activeBackend, e.target.value === modelsDefault ? null : e.target.value)} disabled={disabled || isStreaming || rewriteActive} title="Model for your next message" className="h-10 max-w-[170px] px-2.5 bg-surface-raised border border-border rounded-xl text-[13px] text-text-secondary outline-none focus:border-accent/50 cursor-pointer shrink-0 disabled:opacity-30 truncate" > - {availableModels.some(m => m.provider === 'anthropic') && ( + {scopedModels.some(m => m.provider === 'anthropic') && ( - {availableModels.filter(m => m.provider === 'anthropic').map(m => ( + {scopedModels.filter(m => m.provider === 'anthropic').map(m => ( ))} )} - {availableModels.some(m => m.provider === 'ollama') && ( + {scopedModels.some(m => m.provider === 'ollama') && ( - {availableModels.filter(m => m.provider === 'ollama').map(m => ( + {scopedModels.filter(m => m.provider === 'ollama').map(m => ( + + ))} + + )} + {scopedModels.some(m => m.provider === 'openai') && ( + + {scopedModels.filter(m => m.provider === 'openai').map(m => ( ))} diff --git a/web/src/components/Chat/InteractiveQuestionCard.tsx b/web/src/components/Chat/InteractiveQuestionCard.tsx new file mode 100644 index 0000000..5af04f1 --- /dev/null +++ b/web/src/components/Chat/InteractiveQuestionCard.tsx @@ -0,0 +1,160 @@ +import { useEffect, useMemo, useState } from 'react'; +import { ExternalLink, MessageCircleQuestion, Send, X } from 'lucide-react'; +import { useChatStore } from '../../stores/chatStore'; + +interface QuestionOption { + label: string; + description?: string; + value?: string; +} + +interface Question { + id?: string; + question: string; + header?: string; + options?: QuestionOption[]; + multiSelect?: boolean; + freeText?: boolean; + allowOther?: boolean; + isSecret?: boolean; + required?: boolean; +} + +/** Out-of-band Codex request_user_input / MCP elicitation prompt. */ +export function InteractiveQuestionCard() { + const pending = useChatStore(s => s.pendingInteraction); + const answer = useChatStore(s => s.answerInteraction); + const deny = useChatStore(s => s.denyInteraction); + const [selected, setSelected] = useState>({}); + const [text, setText] = useState>({}); + + const input = pending?.toolInput ?? {}; + const visible = pending?.interactionType === 'question' && input.outOfBand === true; + const questions = useMemo( + () => (Array.isArray(input.questions) ? input.questions as Question[] : []), + [input.questions], + ); + useEffect(() => { + setSelected({}); + setText({}); + }, [pending?.interactionId]); + if (!visible) return null; + + const keyFor = (q: Question) => q.id || q.question; + const complete = questions.every((q) => { + if (q.required === false) return true; + const key = keyFor(q); + return Boolean(text[key]?.trim() || selected[key]?.length); + }); + + const choose = (q: Question, value: string) => { + const key = keyFor(q); + setSelected((current) => { + const prior = current[key] ?? []; + const next = q.multiSelect + ? (prior.includes(value) ? prior.filter(v => v !== value) : [...prior, value]) + : [value]; + return { ...current, [key]: next }; + }); + if (!q.multiSelect) setText(current => ({ ...current, [key]: '' })); + }; + + const submit = () => { + const result: Record = {}; + for (const q of questions) { + const key = keyFor(q); + const free = text[key]?.trim(); + const choices = selected[key] ?? []; + if (free) result[key] = free; + else if (choices.length) result[key] = choices.join(', '); + } + answer(result); + }; + + const url = typeof input.url === 'string' ? input.url : ''; + const message = typeof input.message === 'string' ? input.message : ''; + + return ( +
    +
    + + + {message || 'Codex needs your input'} + + waiting +
    +
    + {url && ( + + + {url} + + )} + {questions.map((q) => { + const key = keyFor(q); + const options = q.options ?? []; + const showText = q.freeText || q.allowOther; + return ( +
    +
    + {q.header &&
    {q.header}
    } +
    {q.question}
    +
    + {options.length > 0 && ( +
    + {options.map((option) => { + const value = option.value ?? option.label; + const active = (selected[key] ?? []).includes(value); + return ( + + ); + })} +
    + )} + {showText && ( + setText(current => ({ ...current, [key]: event.target.value }))} + onKeyDown={(event) => { if (event.key === 'Enter' && complete) submit(); }} + placeholder={q.allowOther && options.length ? 'Other…' : 'Type your answer…'} + autoComplete="off" + className="w-full px-2.5 py-2 rounded border border-border bg-bg-sunken text-[13px] text-text-primary outline-none focus:border-accent/50" + /> + )} +
    + ); + })} +
    +
    + + +
    +
    + ); +} diff --git a/web/src/components/Layout/NavRail.tsx b/web/src/components/Layout/NavRail.tsx index c6b5036..426a668 100644 --- a/web/src/components/Layout/NavRail.tsx +++ b/web/src/components/Layout/NavRail.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; -import { MessageSquare, FolderOpen, CheckSquare, Inbox, Activity, Brain, LogOut, Clock, Lightbulb, Sparkles, Bell, Plug, Users } from 'lucide-react'; +import { MessageSquare, FolderOpen, CheckSquare, Inbox, Activity, Brain, LogOut, Clock, Lightbulb, Sparkles, Bell, Plug, Users, Workflow } from 'lucide-react'; import { useAuthStore } from '../../stores/authStore'; import { useNotificationStore } from '../../stores/notificationStore'; import { ws } from '../../api/websocket'; @@ -16,6 +16,7 @@ const NAV_ITEMS = [ { path: '/skills', icon: Sparkles, label: 'Skills' }, { path: '/mcp', icon: Plug, label: 'MCP' }, { path: '/houseofagents', icon: Users, label: 'HoA', feature: 'hoa' as const }, + { path: '/ultracode', icon: Workflow, label: 'Ultra', feature: 'ultracode' as const }, { path: '/sources', icon: Inbox, label: 'Sources' }, { path: '/cron', icon: Clock, label: 'Cron' }, { path: '/memory', icon: Brain, label: 'Memory' }, @@ -29,15 +30,20 @@ export function NavRail() { const pendingCount = useNotificationStore(s => s.pendingCount); const loadNotifications = useNotificationStore(s => s.loadNotifications); const [hoaEnabled, setHoaEnabled] = useState(false); + const [ultracodeEnabled, setUltracodeEnabled] = useState(false); // Load notification count + feature flags on mount useEffect(() => { loadNotifications(); api.getHoaStatus().then(s => setHoaEnabled(s.enabled)).catch(() => {}); + // A 404 is expected until the dashboard backend is loaded after restart; + // keep the currently-running UI unchanged in that case. + api.getUltracodeDashboardStatus().then(s => setUltracodeEnabled(s.enabled)).catch(() => {}); }, []); const visibleItems = NAV_ITEMS.filter(item => { if (item.feature === 'hoa' && !hoaEnabled) return false; + if (item.feature === 'ultracode' && !ultracodeEnabled) return false; return true; }); diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index 79db67e..73dd432 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -5,6 +5,7 @@ import { SessionSidebar } from '../components/Chat/SessionSidebar'; import { MessageList } from '../components/Chat/MessageList'; import { ChatInput } from '../components/Chat/ChatInput'; import { ApprovalCard } from '../components/Chat/ApprovalCard'; +import { InteractiveQuestionCard } from '../components/Chat/InteractiveQuestionCard'; import { ContextBar } from '../components/Chat/ContextBar'; import { TodoPanel } from '../components/Chat/TodoPanel'; import { SidePanel } from '../components/Chat/SidePanel'; @@ -39,9 +40,10 @@ export function ChatPage() { const { sessions, activeSession, virtualSession, messages, streamingBlocks, isStreaming, loading, - agentStatus, contextUsage, currentTodos, currentCCTasks, + agentStatus, contextUsage, backendStatus, currentTodos, currentCCTasks, sidebarCollapsed, panels, modifiedFiles, modifiedFilesCount, + backendDefault, newChatBackend, loadSessions, switchSession, createSession, deleteSession, sendMessage, stopSession, toggleSidebar, openFilesPanel, } = useChatStore(); @@ -203,6 +205,23 @@ export function ChatPage() { ? 'New chat' : (sessions.find(s => s.id === activeSession)?.title || activeSession)} + {(() => { + const backend = virtualSession?.id === activeSession + ? (newChatBackend ?? backendDefault ?? 'claude') + : (sessions.find(s => s.id === activeSession)?.backend ?? 'claude'); + return ( + + {backend} + + ); + })()} {(() => { const model = sessions.find(s => s.id === activeSession)?.model; return model ? ( @@ -217,6 +236,20 @@ export function ChatPage() { {statusLabel} )} + {backendStatus?.subtype === 'codex_rate_limits' && (() => { + const rateLimits = backendStatus.data.rateLimits as + | { primary?: { usedPercent?: number } } + | undefined; + const used = rateLimits?.primary?.usedPercent; + return ( + + Codex limit{typeof used === 'number' ? ` ${used}% used` : ' updated'} + + ); + })()}
    + ; + } + if (value === 'cancelled' || value === 'canceled' || value === 'refuted') { + return ; + } + if (isFailureStatus(value)) { + return ; + } + if (isActiveStatus(value)) { + return ; + } + return ; +} + +function runTitle(run: UltracodeRun): string { + return run.name || run.display_name || run.task || run.slug || run.id; +} + +function formatCount(value: number | null | undefined): string { + const number = Number(value || 0); + if (number >= 1_000_000_000) return `${(number / 1_000_000_000).toFixed(1)}B`; + if (number >= 1_000_000) return `${(number / 1_000_000).toFixed(1)}M`; + if (number >= 1_000) return `${(number / 1_000).toFixed(1)}K`; + return number.toLocaleString(); +} + +function formatDuration(ms: number | null | undefined): string { + if (ms === null || ms === undefined || !Number.isFinite(ms)) return '—'; + const seconds = Math.max(0, Math.floor(ms / 1000)); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ${seconds % 60}s`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ${minutes % 60}m`; + const days = Math.floor(hours / 24); + return `${days}d ${hours % 24}h`; +} + +function runDuration(run: UltracodeRun): number | null { + if (typeof run.duration_ms === 'number') return run.duration_ms; + if (!run.started_at) return null; + const started = Date.parse(run.started_at); + const ended = run.completed_at ? Date.parse(run.completed_at) : Date.now(); + return Number.isFinite(started) && Number.isFinite(ended) ? Math.max(0, ended - started) : null; +} + +function relativeTime(value?: string | null): string { + if (!value) return 'unknown time'; + const timestamp = Date.parse(value); + if (!Number.isFinite(timestamp)) return value; + const seconds = Math.max(0, Math.floor((Date.now() - timestamp) / 1000)); + if (seconds < 60) return 'just now'; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + if (days < 7) return `${days}d ago`; + return new Date(timestamp).toLocaleDateString(); +} + +function formatTimestamp(value?: string | null): string { + if (!value) return '—'; + const timestamp = Date.parse(value); + return Number.isFinite(timestamp) ? new Date(timestamp).toLocaleString() : value; +} + +function usageTotal(usage?: UltracodeUsage): number { + if (!usage) return 0; + if (typeof usage.total_tokens === 'number') return usage.total_tokens; + return (usage.input_tokens || 0) + (usage.output_tokens || 0); +} + +function cacheRate(usage?: UltracodeUsage): string { + const input = usage?.input_tokens || 0; + const cached = usage?.cached_input_tokens || 0; + if (!input) return '—'; + return `${Math.min(100, Math.round((cached / input) * 100))}%`; +} + +function stepsFor(run: UltracodeRun | null): UltracodeRunStep[] { + if (!run) return []; + if (Array.isArray(run.steps) && run.steps.length > 0) return run.steps; + return Array.isArray(run.workers) ? run.workers : []; +} + +function workerCount(run: UltracodeRun): number { + if (typeof run.workers === 'number') return run.workers; + if (Array.isArray(run.workers)) return run.workers.length; + return run.steps?.length || 0; +} + +function formatValue(value: unknown, limit = 16_000): { text: string; truncated: boolean } { + if (value === undefined || value === null) return { text: '', truncated: false }; + let text: string; + if (typeof value === 'string') { + text = value; + } else { + try { + text = JSON.stringify(value, null, 2); + } catch { + text = String(value); + } + } + if (text.length <= limit) return { text, truncated: false }; + return { text: `${text.slice(0, limit)}\n…`, truncated: true }; +} + +function StatCard({ icon, label, value, detail }: { + icon: ReactNode; + label: string; + value: string; + detail?: string; +}) { + return ( +
    +
    + {icon} + {label} +
    +
    {value}
    + {detail &&
    {detail}
    } +
    + ); +} + +function RunListItem({ run, selected, onSelect }: { + run: UltracodeRun; + selected: boolean; + onSelect: () => void; +}) { + const usage = run.aggregate_usage; + return ( + + ); +} + +function StepCard({ step, index }: { step: UltracodeRunStep; index: number }) { + const output = formatValue(step.error || (step.result ?? step.value)); + const title = step.title || step.label || step.step_id || step.id || `Agent ${index + 1}`; + const dependencies = step.depends_on || (Array.isArray(step.spec?.depends_on) ? step.spec.depends_on as string[] : []); + return ( +
    + +
    + +
    +
    + {title} + {step.kind && ( + + {step.kind} + + )} +
    +
    + {step.model && {step.model}} + {step.reasoning_effort && {step.reasoning_effort}} + {dependencies.length > 0 && ( + + {dependencies.length} dep{dependencies.length === 1 ? '' : 's'} + + )} +
    +
    +
    +
    {formatDuration(step.duration_ms)}
    + {step.usage &&
    {formatCount(usageTotal(step.usage))} tok
    } +
    + +
    +
    +
    + {output.text ? ( + <> +
    +              {output.text}
    +            
    + {output.truncated && ( +
    Output truncated in the dashboard.
    + )} + + ) : ( +
    No output recorded yet.
    + )} +
    +
    + ); +} + +function EventRow({ event }: { event: UltracodeRunEvent }) { + const hasFailure = isFailureStatus(event.status) || event.type?.includes('failed'); + const active = event.type?.includes('started') || event.type?.includes('running'); + const eventData = formatValue(event.data, 2_000); + return ( +
    +
    +
    +
    +
    +
    + {event.type || 'event'} + {event.label && · {event.label}} +
    + {event.message &&
    {event.message}
    } + {eventData.text && ( +
    +              {eventData.text}
    +            
    + )} +
    + +
    +
    + ); +} + +function EmptyDashboard({ message, onRefresh }: { message: string; onRefresh: () => void }) { + return ( +
    +
    +
    + +
    +

    No Ultracode runs to show

    +

    {message}

    + +
    +
    + ); +} + +export function UltracodePage() { + const [runs, setRuns] = useState([]); + const [selectedId, setSelectedId] = useState(null); + const [selectedRun, setSelectedRun] = useState(null); + const [tab, setTab] = useState('steps'); + const [loading, setLoading] = useState(true); + const [detailLoading, setDetailLoading] = useState(false); + const [refreshing, setRefreshing] = useState(false); + const [error, setError] = useState(null); + const selectedIdRef = useRef(null); + + useEffect(() => { + selectedIdRef.current = selectedId; + }, [selectedId]); + + const steps = useMemo(() => stepsFor(selectedRun), [selectedRun]); + const events = useMemo(() => [...(selectedRun?.events || [])].reverse().slice(0, 200), [selectedRun]); + const active = Boolean(selectedRun && selectedRun.id === selectedId && isActiveStatus(selectedRun.status)); + + const refreshAll = useCallback(async (showSpinner = true) => { + if (showSpinner) setRefreshing(true); + try { + const list = await api.listUltracodeRuns(50); + const nextRuns = list.runs || []; + setRuns(nextRuns); + const currentId = selectedIdRef.current; + const nextId = currentId && nextRuns.some(run => run.id === currentId) + ? currentId + : nextRuns[0]?.id || null; + if (nextId) { + const detail = await api.getUltracodeRun(nextId); + setSelectedRun(detail.run); + } else { + setSelectedRun(null); + } + setSelectedId(nextId); + setError(null); + } catch (reason) { + setError(reason instanceof Error ? reason.message : String(reason)); + } finally { + setLoading(false); + if (showSpinner) setRefreshing(false); + } + }, []); + + useEffect(() => { + void refreshAll(false); + }, [refreshAll]); + + useEffect(() => { + if (!selectedId || selectedRun?.id === selectedId) return; + let cancelled = false; + setDetailLoading(true); + api.getUltracodeRun(selectedId) + .then(({ run }) => { + if (!cancelled) { + setSelectedRun(run); + setError(null); + } + }) + .catch(reason => { + if (!cancelled) setError(reason instanceof Error ? reason.message : String(reason)); + }) + .finally(() => { + if (!cancelled) setDetailLoading(false); + }); + return () => { cancelled = true; }; + }, [selectedId, selectedRun?.id]); + + useEffect(() => { + if (!selectedId || !active) return; + let cancelled = false; + let inFlight = false; + const poll = async () => { + if (cancelled || inFlight) return; + inFlight = true; + try { + const [detail, list] = await Promise.all([ + api.getUltracodeRun(selectedId), + api.listUltracodeRuns(50), + ]); + if (!cancelled) { + setSelectedRun(detail.run); + setRuns(list.runs || []); + setError(null); + } + } catch (reason) { + if (!cancelled) setError(reason instanceof Error ? reason.message : String(reason)); + } finally { + inFlight = false; + } + }; + const timer = window.setInterval(() => { void poll(); }, 2_000); + return () => { + cancelled = true; + window.clearInterval(timer); + }; + }, [active, selectedId]); + + const usage = selectedRun?.aggregate_usage; + const topResult = formatValue(selectedRun?.error || selectedRun?.result); + const failedSteps = steps.filter(step => isFailureStatus(step.status)).length; + const completedSteps = steps.filter(step => normalizedStatus(step.status) === 'completed').length; + + return ( +
    +
    +
    +
    + +
    +
    +
    +

    Ultracode

    + + read only + + {active && ( + + + + + + live + + )} +
    +

    Parallel worker runs and execution journals

    +
    +
    + +
    + + {error && ( +
    + + {error} +
    + )} + +
    + + +
    + {!selectedRun && !detailLoading ? ( + { void refreshAll(); }} + /> + ) : detailLoading && !selectedRun ? ( +
    + ) : selectedRun ? ( +
    +
    +
    +
    +
    +
    + +

    {runTitle(selectedRun)}

    + + {normalizedStatus(selectedRun.status).replace(/_/g, ' ')} + +
    + {selectedRun.task && selectedRun.task !== runTitle(selectedRun) && ( +

    {selectedRun.task}

    + )} +
    + {selectedRun.cwd || 'workspace'} + + started {relativeTime(selectedRun.started_at)} + + {selectedRun.id} +
    +
    +
    +
    + +
    + } + label="Elapsed" + value={formatDuration(runDuration(selectedRun))} + detail={selectedRun.completed_at ? `ended ${relativeTime(selectedRun.completed_at)}` : 'in progress'} + /> + } + label="Agents" + value={String(steps.length || workerCount(selectedRun))} + detail={`${completedSteps} done${failedSteps ? ` · ${failedSteps} failed` : ''}`} + /> + } + label="Tokens" + value={formatCount(usageTotal(usage))} + detail={`${formatCount(usage?.output_tokens)} output`} + /> + } + label="Cache hit" + value={cacheRate(usage)} + detail={`${formatCount(usage?.cached_input_tokens)} cached input`} + /> +
    + + {usage && ( +
    +
    +
    +
    Usage
    +
    Aggregate across every worker in this run
    +
    +
    + {[ + ['Input', usage.input_tokens], + ['Cached', usage.cached_input_tokens], + ['Output', usage.output_tokens], + ['Reasoning', usage.reasoning_output_tokens], + ].map(([label, value]) => ( +
    +
    {label}
    +
    {formatCount(value as number | undefined)}
    +
    + ))} +
    +
    +
    + )} + +
    +
    + {([ + ['steps', `Agents ${steps.length}`], + ['events', `Events ${selectedRun.events?.length || 0}`], + ['result', 'Run detail'], + ] as Array<[DetailTab, string]>).map(([id, label]) => ( + + ))} +
    + + {tab === 'steps' && ( +
    + {steps.length > 0 ? steps.map((step, index) => ( + + )) : ( +
    + No worker records in this journal. +
    + )} +
    + )} + + {tab === 'events' && ( +
    + {events.length > 0 ? events.map((event, index) => ( + + )) : ( +
    No journal events recorded.
    + )} + {(selectedRun.events?.length || 0) > events.length && ( +
    + Showing the latest {events.length} events. +
    + )} +
    + )} + + {tab === 'result' && ( +
    +
    +
    + Final result +
    +
    + {topResult.text ? ( + <> +
    +                                {topResult.text}
    +                              
    + {topResult.truncated &&
    Result truncated in the dashboard.
    } + + ) : ( +
    No top-level result recorded.
    + )} +
    +
    +
    +
    + Run options +
    +
    +                          {formatValue(selectedRun.options || {}).text}
    +                        
    +
    +
    + )} +
    +
    +
    + ) : null} +
    +
    +
    + ); +} diff --git a/web/src/stores/chatStore.ts b/web/src/stores/chatStore.ts index 38e9eef..f1baad9 100644 --- a/web/src/stores/chatStore.ts +++ b/web/src/stores/chatStore.ts @@ -9,7 +9,7 @@ import { randomUUID } from '../utils/uuid'; import { cancelAutoClose, clearAllAutoCloseTimers, MAX_COMPLETED_TABS } from './helpers/blockHelpers'; import { extractTodosFromMessages, extractCCTasksFromMessages } from './helpers/bufferReplay'; // Handlers -import { handleThinking, handleToken, handleToolUse, handleToolResult, handleDone, handleStopped, handleError, handleWakeup, handleAutoTurn, handleModelChanged } from './handlers/streamingHandlers'; +import { handleThinking, handleToken, handleToolUse, handleToolResult, handleToolOutput, handleDone, handleStopped, handleError, handleWakeup, handleAutoTurn, handleModelChanged } from './handlers/streamingHandlers'; import { handleSessionUpdated, handleSessionStatus, handleSessionSwitched, handleSessionForked, handleSessionResumed, handleSessionArchived, handleSessionRunning, handleSessionAwaitingInput, handleAnswerInjected, handleUserMessage } from './handlers/sessionHandlers'; import { handlePlanUpdate, handleSubagentStart, handleSubagentComplete, handleHoaProgress, handleWorkflowProgress } from './handlers/panelHandlers'; import { handleInteraction, handleInteractionResolved, handleFileChanged, handleNotification, handleNotificationAnswered, handleNotificationExpired, handleBackgroundTasksUpdate } from './handlers/auxiliaryHandlers'; @@ -63,8 +63,9 @@ let _quoteId = 0; // disabled composer. Sidebar/list events (session_running, session_updated, …) // stay unguarded so background sessions keep updating their row. const VIEW_SCOPED_EVENTS = new Set([ - 'thinking', 'token', 'tool_use', 'tool_result', 'done', 'stopped', 'error', + 'thinking', 'token', 'tool_use', 'tool_result', 'tool_output', 'done', 'stopped', 'error', 'wakeup', 'auto_turn', 'model_changed', 'session_status', 'plan_update', + 'backend_status', 'subagent_start', 'subagent_complete', 'hoa_progress', 'interaction', 'interaction_resolved', 'file_changed', ]); @@ -95,6 +96,7 @@ interface ChatState { max_context_tokens: number; num_turns: number; } | null; + backendStatus: { subtype: string; data: Record } | null; // TodoWrite panel state (legacy Claude Code todos) currentTodos: TodoItem[]; // Claude Code 2.1+ task panel state (TaskCreate / TaskUpdate / TaskList) @@ -141,15 +143,15 @@ interface ChatState { // Composer model picker: options from GET /api/models (Anthropic default + // locally-installed Ollama models), the server's default id, and the user's // current pick (null = use the server default). - availableModels: { id: string; provider: string }[]; - modelsDefault: string | null; + availableModels: { id: string; provider: string; backend: string }[]; + modelDefaults: Record; // Agent backends for the new-chat selector (claude / codex). - backendOptions: { id: string; label: string; model: string }[]; + backendOptions: { id: string; label: string; model: string; models?: string[]; available?: boolean; reason?: string }[]; backendDefault: string | null; // Backend picked for the CURRENT virtual (unsent) chat; null = default. // Bound at session materialization (ensureRealSession) and reset after. newChatBackend: string | null; - selectedModel: string | null; + selectedModels: Record; loadSessions: () => Promise; switchSession: (id: string) => Promise; @@ -175,7 +177,7 @@ interface ChatState { loadModels: () => Promise; setNewChatBackend: (backend: string | null) => void; /** Set the model for the next message (null → server default). */ - setSelectedModel: (model: string | null) => void; + setSelectedModel: (backend: string, model: string | null) => void; stopSession: () => void; handleWSMessage: (msg: WSMessage) => void; addQuote: (text: string, action: QuoteAction) => void; @@ -212,6 +214,7 @@ export const useChatStore = create((set, get) => ({ loading: false, agentStatus: { state: 'idle' }, contextUsage: null, + backendStatus: null, currentTodos: [], currentCCTasks: [], quotes: [], @@ -231,11 +234,15 @@ export const useChatStore = create((set, get) => ({ searchLoading: false, searchFocusNonce: 0, availableModels: [], - modelsDefault: null, + modelDefaults: {}, backendOptions: [], backendDefault: null, newChatBackend: null, - selectedModel: localStorage.getItem('nerve_selected_model') || null, + selectedModels: { + claude: localStorage.getItem('nerve_selected_model_claude') + || localStorage.getItem('nerve_selected_model') || null, + codex: localStorage.getItem('nerve_selected_model_codex') || null, + }, addQuote: (text: string, action: QuoteAction) => { const id = `q${++_quoteId}`; @@ -431,6 +438,7 @@ export const useChatStore = create((set, get) => ({ set({ activeSession: id, messages: [], loading: true, streamingBlocks: [], isStreaming: false, agentStatus: { state: 'idle' }, contextUsage: null, + backendStatus: null, currentTodos: [], currentCCTasks: [], pendingInteraction: null, panels: [], activePanelId: null, panelVisible: false, modifiedFiles: [], modifiedFilesCount: 0, backgroundTasks: [], @@ -635,16 +643,22 @@ export const useChatStore = create((set, get) => ({ set((state) => { // Drop a stale pick (e.g. an Ollama model no longer installed) so we // never send a model the server can't route. - const ids = new Set(res.models.map(m => m.id)); - const keep = state.selectedModel && ids.has(state.selectedModel) - ? state.selectedModel : null; - if (keep !== state.selectedModel) localStorage.removeItem('nerve_selected_model'); + const selectedModels = { ...state.selectedModels }; + for (const backend of ['claude', 'codex']) { + const ids = new Set(res.models.filter(m => m.backend === backend).map(m => m.id)); + const selected = selectedModels[backend]; + if (selected && !ids.has(selected)) { + selectedModels[backend] = null; + localStorage.removeItem(`nerve_selected_model_${backend}`); + } + } + localStorage.removeItem('nerve_selected_model'); return { availableModels: res.models, - modelsDefault: res.default, + modelDefaults: res.defaults ?? { claude: res.default }, backendOptions: res.backends?.options ?? [], backendDefault: res.backends?.default ?? null, - selectedModel: keep, + selectedModels, }; }); } catch (e) { @@ -654,10 +668,13 @@ export const useChatStore = create((set, get) => ({ setNewChatBackend: (backend: string | null) => set({ newChatBackend: backend }), - setSelectedModel: (model: string | null) => { - if (model) localStorage.setItem('nerve_selected_model', model); - else localStorage.removeItem('nerve_selected_model'); - set({ selectedModel: model }); + setSelectedModel: (backend: string, model: string | null) => { + const key = `nerve_selected_model_${backend}`; + if (model) localStorage.setItem(key, model); + else localStorage.removeItem(key); + set((state) => ({ + selectedModels: { ...state.selectedModels, [backend]: model }, + })); }, sendMessage: async (content: string, fileIds?: string[], imageBlocks?: Array<{ url: string; filename: string; media_type: string }>) => { @@ -699,7 +716,12 @@ export const useChatStore = create((set, get) => ({ return; } } - const status = ws.sendMessage(content, session, fileIds, get().selectedModel ?? undefined); + const state = get(); + const backend = state.sessions.find(s => s.id === session)?.backend + ?? state.backendDefault ?? 'claude'; + const status = ws.sendMessage( + content, session, fileIds, state.selectedModels[backend] ?? undefined, + ); if (status === 'dropped') { // The message could not reach the server. Revert the optimistic // state and surface the failure inline so the user knows to retry. @@ -739,6 +761,7 @@ export const useChatStore = create((set, get) => ({ case 'token': return handleToken(msg, get, set); case 'tool_use': return handleToolUse(msg, get, set); case 'tool_result': return handleToolResult(msg, get, set); + case 'tool_output': return handleToolOutput(msg, get, set); case 'done': return handleDone(msg, get, set); case 'wakeup': return handleWakeup(msg, get, set); case 'auto_turn': return handleAutoTurn(msg, get, set); @@ -758,6 +781,9 @@ export const useChatStore = create((set, get) => ({ case 'user_message': return handleUserMessage(msg, get, set); // Panels case 'plan_update': return handlePlanUpdate(msg, get, set); + case 'backend_status': + set({ backendStatus: { subtype: msg.subtype, data: msg.data } }); + return; case 'subagent_start': return handleSubagentStart(msg, get, set); case 'subagent_complete': return handleSubagentComplete(msg, get, set); case 'hoa_progress': return handleHoaProgress(msg, get, set); diff --git a/web/src/stores/handlers/streamingHandlers.ts b/web/src/stores/handlers/streamingHandlers.ts index c50580d..d18a08e 100644 --- a/web/src/stores/handlers/streamingHandlers.ts +++ b/web/src/stores/handlers/streamingHandlers.ts @@ -383,6 +383,29 @@ export function handleToolResult( } } +export function handleToolOutput( + msg: Extract, + _get: Get, + set: Set, +): void { + const parentId = msg.parent_tool_use_id; + if (parentId) { + // Child-command live output remains visible in the parent panel once the + // final tool_result arrives; avoid manufacturing a completed result here. + return; + } + set(state => ({ + streamingBlocks: state.streamingBlocks.map(block => { + if (block.type !== 'tool_call' || block.toolUseId !== msg.tool_use_id) return block; + return { + ...block, + result: `${block.result ?? ''}${msg.content}`, + status: 'running' as const, + }; + }), + })); +} + // ------------------------------------------------------------------ // // Turn lifecycle: done, stopped, error // // ------------------------------------------------------------------ // diff --git a/web/src/types/chat.ts b/web/src/types/chat.ts index f73b3b7..d0bb584 100644 --- a/web/src/types/chat.ts +++ b/web/src/types/chat.ts @@ -36,6 +36,8 @@ export interface WorkflowAgent { /** queued | running | done | failed (CLI vocabulary) */ state?: string; model?: string; + backend?: string; + cwd?: string; tokens?: number; toolCalls?: number; lastToolName?: string; @@ -113,6 +115,8 @@ export interface Session { message_count?: number; total_cost_usd?: number; model?: string; + backend?: string; + cwd?: string; // Real-time running status (set by backend + WS updates) is_running?: boolean; // Paused mid-turn waiting for user input (AskUserQuestion / plan mode). From 10f1648568ba940020211884c3092103f3fca1ef Mon Sep 17 00:00:00 2001 From: pufit Date: Sat, 11 Jul 2026 13:13:50 -0400 Subject: [PATCH 7/8] Gitignore docs/plans, drop plan doc from the branch --- .gitignore | 3 + docs/plans/codex-backend.md | 833 ------------------------------------ 2 files changed, 3 insertions(+), 833 deletions(-) delete mode 100644 docs/plans/codex-backend.md diff --git a/.gitignore b/.gitignore index 002a57c..2e0cbcd 100644 --- a/.gitignore +++ b/.gitignore @@ -57,3 +57,6 @@ docker-entrypoint.sh # OS .DS_Store Thumbs.db + +# Local planning docs — not for PRs +docs/plans/ diff --git a/docs/plans/codex-backend.md b/docs/plans/codex-backend.md deleted file mode 100644 index d855004..0000000 --- a/docs/plans/codex-backend.md +++ /dev/null @@ -1,833 +0,0 @@ -# Codex Backend — Multi-Backend Agent Engine - -**Status:** implemented; 2026-07-11 integration-audit remediation complete -**Branch:** `pufit/codex-backend` -**Goal:** run Nerve sessions on OpenAI Codex (GPT‑5.6 family) with **full parity** to the -Claude path — interactive web sessions, Telegram, cron, wakeups, forks, resume — behind a -clean backend abstraction. Claude remains the default; the backend is selected by config -and **sticky per session**. - -The 2026-07-11 hardening pass added migration v039 (legacy backend backfill, -native thread/turn mapping, persisted cwd, cost basis), backend-scoped model -preflight, exact-point Codex forks, narrow resume-miss recovery, dedicated -loopback MCP ASGI serving, expiring credentials, canonical protocol checks, -non-dropping terminal transport behavior, structured user-input/plan/output -bridges, external-thread convergence, and the pinned managed Ultracode layer. - ---- - -## 0. Ground truth (verified 2026-07-10 against codex-cli 0.144.1 exported schema) - -### Nerve today - -- `AgentEngine` (nerve/agent/engine.py, ~3900 lines) is hardwired to - `claude_agent_sdk.ClaudeSDKClient`. One persistent client (= one CLI subprocess) per - session; `client.query()` per turn; `client.receive_response()` streamed and translated - in `_process_sdk_message()` into broadcaster events + `_TurnState` accumulation. -- Session persistence: `sessions.sdk_session_id` + SDK `resume` / `fork_session`; - claude resume targets validated by `_sdk_resume_file_exists` (engine.py:1539) — a - Claude-CLI-specific jsonl check. -- Interactive pausing: `can_use_tool` callback → `InteractiveToolHandler` - (AskUserQuestion / EnterPlanMode / ExitPlanMode pause mid-turn; everything else - auto-approved). Non-web sources auto-deny interactive tools. The handler currently - imports `claude_agent_sdk.types.PermissionResult*`. -- Hooks: PreToolUse (file snapshots for diff view, image validation, background-agent - permission parity), PostToolUse (ScheduleWakeup capture → nerve's wakeup sweep; - wakeups fire back into the *same session* via `engine.run(..., source="wakeup")`). -- Tools: runtime-agnostic `ToolRegistry`/`ToolContext`/`ToolResult` with two adapters - in-tree: in-process SDK MCP (claude_sdk_adapter.py) and the **Streamable HTTP MCP - endpoint** (nerve/mcp_server/, `/mcp/v1`, JWT via `gateway.auth.decode_token`, - per-request `ctx_resolver`, `SatelliteSessionResolver` for external clients). -- Cost: SDK reports *cumulative* `total_cost_usd`; `_finalize_turn` persists a - high-water mark (`meta["_sdk_cumulative_cost"]`) and `compute_turn_cost` diffs it; - `estimate_turn_cost` falls back to a DEFAULT_PRICING table when the model is unknown. -- Usage dict keys are Anthropic-shaped everywhere downstream: `_finalize_turn` reads - `input_tokens` / `cache_read_input_tokens` / `cache_creation_input_tokens`, cache-ttl - split reads `cache_creation.ephemeral_*`, and the web UI reads the same keys off the - `done` event. -- Autonomous CLI turns: `_idle_stream_watcher` + `_drain_pending_messages` probe the - SDK's buffered stream non-blockingly, park with timeouts, and frame turns off - `system/init` messages — Claude-specific capability. - -### Codex (codex-cli 0.144.1; `codex app-server`; schema exported via -`codex app-server generate-json-schema`) - -- Long-lived subprocess, bidirectional **JSON-RPC 2.0 over stdio** (JSONL). -- Client→server (subset we use): `initialize` (clientInfo + capabilities incl. - `optOutNotificationMethods`), `thread/start`, `thread/resume`, `thread/fork`, - `turn/start`, `turn/interrupt` (`{threadId, turnId}`), `model/list`, `account/read`, - `account/login/start {type: apiKey}`. -- `ThreadStartParams`: `model`, `cwd`, `sandbox` (SandboxMode: - `read-only|workspace-write|danger-full-access`), `approvalPolicy` (AskForApproval: - `untrusted|on-request|never` — **no `on-failure` in v2**), `baseInstructions`, - `developerInstructions` (also available on resume/fork — needed for client rebuilds), - `config` (free per-thread config-override dict, `additionalProperties: true`; - `mcp_servers`, `project_doc_max_bytes` are valid keys; overrides are ignored for - already-running threads), `ephemeral`, `personality`, `modelProvider`. - `thread/start` response carries `thread.id` immediately. -- `TurnStartParams`: `input: [UserInput]` — text `{type:"text",text}`, images ONLY as - `{type:"image", url}` (data: URLs ok) or `{type:"localImage", path}`; **no PDF input - type**. Per-turn overrides: `model`, `effort` (free string, model-dependent), `cwd`, - `approvalPolicy`, `sandboxPolicy` (SandboxPolicy object — different type from - thread-level SandboxMode), `summary`, `outputSchema`, `personality`. -- Server→client **requests** (turn pauses until the client responds): - `item/commandExecution/requestApproval`, `item/fileChange/requestApproval` - (`{itemId, reason?, grantRoot?}` — no diff payload; correlate via itemId), - `item/permissions/requestApproval`, `item/tool/requestUserInput`, - `mcpServer/elicitation/request`. Decisions include `accept|acceptForSession|decline|cancel`. -- Server→client **notifications** (subset): `thread/started`, `turn/started`, - `item/started`, `item/completed`, `item/agentMessage/delta`, - `item/reasoning/textDelta`, `item/reasoning/summaryTextDelta`, - `item/commandExecution/outputDelta`, `item/fileChange/patchUpdated`, - `item/mcpToolCall/progress`, `item/plan/delta`, **`thread/tokenUsage/updated`** - (`{threadId, turnId, tokenUsage: {last, total, modelContextWindow}}` — THE usage - source), `turn/completed` (`Turn = {id, items, status, startedAt, completedAt, - durationMs, error}` — **no usage field**; `status ∈ - completed|interrupted|failed|inProgress`; `error: TurnError` when failed), generic - `error {error, willRetry}`, `account/rateLimits/updated`, `thread/compacted`, - `model/rerouted`. **There is no `turn/failed` method.** -- Item payloads: `CommandExecutionThreadItem = {command, cwd, commandActions, - aggregatedOutput, exitCode, status...}` (no `description` field); - `FileChangeThreadItem.changes = [{path, kind, diff}]` — an **array** of per-file - changes with unified diffs. -- Token usage: `TokenUsageBreakdown` = input, cached_input, output, reasoning_output, - total. **No cost-in-USD anywhere** → we price it ourselves. -- MCP server config (`RawMcpServerConfig`, confirmed in binary): stdio - (`command/args/env`) and streamable HTTP (`url`, `http_headers`, - `bearer_token_env_var`). MCP tool identifiers keep the `mcp__server__tool` naming, so - existing prompt references to `mcp__nerve__*` hold under codex. -- Official Python SDK (`openai-codex` 0.1.0b2) is a thin wrapper over this protocol, - but dispatches server-requests **synchronously on the reader thread** (a blocking - approval handler stalls all routing incl. the `turn/interrupt` response → deadlock) - and `AsyncCodexClient` accepts no `approval_handler` at all. Interactive pausing is - impossible on it → **we implement our own asyncio-native app-server client** - (~400 lines against a schema-exported protocol; zero new runtime deps). - ---- - -## 1. Design principles - -1. **One seam, two implementations.** Engine keeps everything backend-agnostic - (session lifecycle, DB persistence, broadcasting, turn state, memorization, cron, - locks, idle sweep, turn framing for autonomous drains). Backends own: process/client - lifecycle, option building, native event → normalized event translation, - permission/approval wiring, resume-target validation, usage/cost normalization. -2. **Normalize at the boundary.** Engine consumes only nerve-owned event types. - No `claude_agent_sdk` or codex types cross the seam — including - `InteractiveToolHandler`, whose claude-typed `can_use_tool` adapter moves into the - claude backend (§7). -3. **Capabilities, not isinstance.** Backend differences the engine must act on - (cumulative vs per-turn cost, idle-stream draining, cache-ttl policy, resume - validation, context-window reporting) are declared as flags/methods on the backend. -4. **Sticky backends.** A session's backend is chosen once (at first client build), - persisted in `sessions.backend`, and **always wins over config** afterwards. - Wakeup/internal turns on an existing session can never cross backends. -5. **Zero behavior change for Claude.** Default config keeps `backend: claude`; the - refactor is a pure extraction. The existing suite stays green (files that patch - moved helpers are updated mechanically — enumerated in §12). -6. **Defensive protocol handling.** Unknown notifications → debug log; unknown - server-requests → safe decline/empty response; missing fields never crash a turn. - ---- - -## 2. Package layout - -``` -nerve/agent/backends/ - __init__.py # re-exports + get_backend() registry - base.py # AgentBackend/AgentClient protocols, SessionSpec/TurnInput/BackendCapabilities - events.py # normalized AgentEvent union + NormalizedUsage - claude.py # ClaudeBackend/ClaudeClient — extraction of today's code - codex/ - __init__.py - appserver.py # CodexAppServerClient: asyncio JSON-RPC stdio transport - backend.py # CodexBackend/CodexClient: threads, turns, event mapping - protocol.py # method names, param builders, notification→event mapping helpers - pricing.py # usage → USD from config-driven price table -``` - -## 3. The seam (base.py) - -```python -@dataclass -class SessionSpec: - session_id: str - source: str # web | telegram | cron | wakeup | hook - model: str | None # explicit override; None → backend default for source - effort: str # nerve vocabulary (backend maps via effort_map) - system_prompt: str # fully rendered nerve system prompt - cwd: str - resume_native_id: str | None # backend-native session/thread id - fork: bool # resume_native_id is a parent to fork from - interactive: InteractionHub # backend-neutral pause/approve machinery (§7) - snapshot: SnapshotFn # async (session_id, path, content|None) -> None - record_wakeup: WakeupFn # async (session_id, tool_input) -> None - cache_ttl: str # claude-only; codex ignores - max_turns: int - idle_timeout: float # per-message hang detection (engine-resolved, §10) - extra: dict # escape hatch (betas, thinking, plugins...) - -@dataclass -class TurnInput: - text: str - images: list[dict] | None # engine-normalized: {media_type, data(b64)} | {path} - # claude: native blocks; codex: b64→data: URL / - # path→localImage. PDFs: claude native; codex — - # backend returns UnsupportedInput → engine appends - # a clear inline note instead (no silent drop). - -class AgentClient(Protocol): - @property - def native_session_id(self) -> str | None: ... - # codex: set at thread/start (immediately); claude: first stream message. - # Engine's /stop-mid-turn persistence path reads THIS (replaces the old - # early-capture from raw SDK messages). - async def connect(self) -> None: ... - async def start_turn(self, turn: TurnInput) -> None: ... - def receive_turn(self) -> AsyncIterator[AgentEvent]: ... - # yields until TurnCompleted; MUST also terminate on interrupted turns - async def interrupt(self) -> None: ... - async def disconnect(self) -> None: ... # owns ALL process-teardown internals - # (claude: today's _safe_disconnect body) - def is_alive(self) -> bool: ... - # -- autonomous/idle stream (capability-gated; codex: try→None, receive→raises) -- - def try_receive_idle_event(self) -> AgentEvent | None: ... # never parks - async def receive_idle_event(self, timeout: float) -> AgentEvent | None: ... - def buffer_used(self) -> int: ... # 0 when N/A - -@dataclass(frozen=True) -class BackendCapabilities: - cost_is_cumulative: bool # claude True (engine diffs); codex False (per-turn, precomputed) - supports_idle_stream: bool # claude True; codex False - supports_cache_ttl: bool # claude True; codex False - interactive_builtins: bool # claude True (AskUserQuestion/plan mode) - reports_context_window: bool # codex True (thread/tokenUsage/updated) - -class AgentBackend(Protocol): - name: str # "claude" | "codex" - capabilities: BackendCapabilities - def default_model(self, source: str) -> str: ... - async def create_client(self, spec: SessionSpec) -> AgentClient: ... - def validate_resume_target(self, native_id: str, cwd: str) -> bool: ... - # claude: today's _sdk_resume_file_exists jsonl check. - # codex: returns True (cheap check impossible); stale ids are handled by - # create_client falling back: thread/resume error → clear id → thread/start - # fresh (and the engine is told the id was dropped so it clears the DB column). - def excluded_tools(self) -> set[str]: ... # nerve-registry tools NOT to expose -``` - -**Backend resolution (engine):** -1. `sessions.backend` column set → that backend, always (wakeup/internal/cron-fired - turns on existing sessions inherit it; a missing backend implementation at runtime - is a hard error telling the operator to restore config). -2. Unset (new session) → `agent.cron_backend` for sources `cron|hook`, else - `agent.backend`; stamped into `sessions.backend` at first client build. `wakeup` is - NOT a cron source (matches today's `_CRAN_EFFORT_SOURCES` treatment — wakeups always - land on existing sessions anyway). -3. Session-metadata `backend_override` (set at creation time by API/UI later) wins over - config for new sessions — the A/B hook. -4. Ollama guard: `ollama.enabled` + non-claude model string routes through the claude - CLI proxy **today**; that combination therefore forces the claude backend. A codex - backend with an ollama model is a config-validation error. - -**Model resolution:** explicit `model=` argument wins; else `backend.default_model -(source)` (claude: `agent.model`/`agent.cron_model`; codex: `codex.model`/ -`codex.cron_model`). `run_cron`/`run_persistent_cron`/`run_hook` **stop pre-resolving** -`self.config.agent.cron_model` at their call sites (engine.py:3657/3695/3724) and pass -the job's explicit model or None; same fix in `_get_or_create_client`'s -`requested_model` and the langfuse tag default (engine.py:2973). - -## 4. Normalized events (events.py) - -```python -@dataclass -class NormalizedUsage: - input_tokens: int - output_tokens: int - cache_read_tokens: int - cache_creation_tokens: int # codex: 0 - raw: dict # backend-native payload - - def to_anthropic_shape(self) -> dict: - # THE usage-dict contract: engine persists/broadcasts THIS shape, so - # _finalize_turn, extract_cache_ttl_split (absent keys → zeros), and the - # web UI's done-event reader keep working for both backends: - # {input_tokens, output_tokens, cache_read_input_tokens, - # cache_creation_input_tokens, ...raw passthrough for claude} - # For claude the raw dict IS already this shape and is passed through - # untouched (preserving cache_creation.ephemeral_* for the TTL split). - -AgentEvent = ( - TextDelta(text, parent_tool_use_id=None) - | ThinkingDelta(text, parent_tool_use_id=None) - | ToolUse(tool_use_id, name, input, parent_tool_use_id=None) - | ToolResult(tool_use_id, content, is_error, parent_tool_use_id=None) - | SubagentStarted(tool_use_id, subagent_type, description, model) # claude-only - | ModelObserved(model) # feeds _track_serving_model / st.last_model; - # claude: AssistantMessage.model when - # parent_tool_use_id is None; codex: resolved - # thread model at turn start + model/rerouted - | SystemEvent(subtype, data) # claude system messages (init/task chips/workflow), - # codex plan deltas, rate-limit updates - | TurnCompleted( - native_session_id, model, - usage: NormalizedUsage | None, - total_cost_usd: float | None, # claude: cumulative; codex: THIS turn, precomputed - duration_ms, duration_api_ms, num_turns, - context_window: int | None, # codex: modelContextWindow; claude: None - status: "completed"|"interrupted"|"failed", - error: str | None) -) -``` - -Engine's `_process_sdk_message` becomes `_process_agent_event(session_id, event, st)`: -same accumulation/broadcast body re-keyed on event types. One SDK message may translate -to N events (multi-block AssistantMessage). Claude translation lives in -`claude.py::translate_message()` and is unit-tested for parity (§12). - -## 5. ClaudeBackend (claude.py) — pure extraction, complete inventory - -Moves from engine.py / interactive.py with behavior unchanged: - -- `_build_options` (system-prompt file spill, thinking/effort/betas/extra_args/ - disallowed_tools/env/plugins/mcp_servers), `_parse_thinking_config`, - `_effective_effort`, `_model_family`, `_model_supports_legacy_enabled_thinking`, - `_build_env` (Anthropic/Bedrock/proxy + cache-ttl env), `_build_hooks` (snapshot, - image validation incl. `_validate_image_file`/`_validate_image_data`, wakeup capture - → `spec.record_wakeup`, background-permission hook), the CLI stderr filter, - **`_sdk_resume_file_exists`** (→ `validate_resume_target`), **`_safe_disconnect`** - (→ `ClaudeClient.disconnect()`, keeping the transport/process/task-group teardown - internals), `_is_client_dead`, `_sdk_buffer_used`, `_sdk_message_stream` (→ the - idle-event methods; parsing via the SDK message parser stays inside the backend), - and the `can_use_tool` adapter + `PermissionResult*` mapping from interactive.py (§7). -- `ClaudeClient.start_turn` keeps the image/document async-generator query path - (engine.py:2993) — documents (PDFs) remain claude-native. -- In-process MCP server (claude_sdk_adapter) unchanged, now built with the backend's - exclusion set applied. - -Engine keeps: locks/semaphore, `_TurnState`, broadcasting, DB writes, title generation -(Anthropic direct client — see §10 note), memorization, cron/wakeup services, the -idle-client sweep (calls `client.disconnect()`), `_drain_pending_messages`' **turn -framing** (re-keyed on `SystemEvent("init")` / `TurnCompleted` events; the -probe/park loop uses `try_receive_idle_event` / `receive_idle_event(timeout)`), the -generic per-message timeout wrapper, and cost bookkeeping (§9). - -## 6. CodexAppServerClient (codex/appserver.py) - -Asyncio-native JSON-RPC client; the only transport-aware code: - -- Spawn `codex app-server` via `asyncio.create_subprocess_exec` (stdin/stdout PIPE, - stderr → severity-filtered logger). Env: isolated `CODEX_HOME` (§10), auth env, - `NERVE_MCP_TOKEN` (§8). -- `initialize` with `clientInfo={name:"nerve",...}`; `optOutNotificationMethods` for - surfaces we never consume (realtime/*, fuzzyFileSearch/*). -- Single reader task; dispatch: responses → pending `Future`s; notifications → - per-turn queue + global queue; **server requests** → `asyncio.create_task(handler)`, - reply `{id, result}` on resolution — approvals can await user input indefinitely - without stalling the stream (this is the property the official SDK lacks). - Unknown server-request methods → safe default (decline-shaped for `*Approval`, - `{}` otherwise) + warning. -- `request(method, params, timeout)` / write lock on stdin; EOF → fail all pending - with `CodexTransportError`; `is_alive()` = process poll + reader liveness. Engine's - existing hung-client retry path is reused because `receive_turn` surfaces the same - timeout/error shapes. -- Dicts in, dicts out; no pydantic/codegen dependency. Method names + param builders - centralized in `protocol.py`. A schema version marker - (`tests/fixtures/codex_schema_meta.json`) records what we verified against. - -## 7. CodexBackend / CodexClient (codex/backend.py) + interaction seam - -### Interaction seam (applies to both backends) - -`InteractiveToolHandler` splits: -- **`InteractionHub`** (interactive.py, backend-neutral): pending/resolve/deny/cancel - machinery, WebSocket `interaction` broadcast, `session_awaiting_input`, timeout — - today's code minus claude types, plus `request_approval(kind, payload) -> - ApprovalDecision` reusing the same machinery with new interaction types - `command_approval` / `file_approval`. -- **Claude adapter** (backends/claude.py): `can_use_tool` callback translating hub - decisions into `PermissionResultAllow/Deny` — the only place claude permission types - live. `tests/test_interactive.py`'s type imports update accordingly. -- **Frontend** (in scope — full parity includes tightened-sandbox operation): - extend the interaction type union (`web/src/stores/chatStore.ts`), add a generic - `ApprovalBlock` card (command / file-change variants; file approvals render the - correlated `item/started` payload the backend attaches — the raw request carries no - diff), route answers through the existing `answer_interaction` WS message. Rebuild - the frontend (`npm run build`) as the nerve-dev skill prescribes. - -### Session → thread lifecycle - -- `create_client(spec)`: spawn app-server (one process per nerve session — mirrors the - claude process model so idle sweep / kill / rebuild semantics carry over; RSS cost - measured in the smoke test and recorded here before any wide flip), `initialize`, - then `thread/start` | `thread/resume` | `thread/fork`. - **Resume-miss recovery:** `thread/resume`/`thread/fork` JSON-RPC error → log, report - `resume_dropped` to the engine (clears `sessions.sdk_session_id`), `thread/start` - fresh. Never brick a session on a wiped `~/.nerve/codex/sessions`. -- Thread params: `cwd`, `model` (resolved per §3), `developerInstructions = - spec.system_prompt + backend notes` (see §9-prompt), `sandbox` + `approvalPolicy` - from config (defaults `danger-full-access` + `never` = parity with claude - auto-approve), `config` override dict: `mcp_servers` (nerve + translated external - servers, §8), root-workspace-only `project_doc_max_bytes: 0`, web-search toggle. -- `native_session_id` = thread id (known at `thread/start` — available to the engine's - cancel-persistence path immediately). - -### Turns - -- `start_turn`: `turn/start {threadId, input:[text + images (data:-URL / localImage)], - effort: effort_map[spec.effort], model when overridden}`; capture `turn.id` - (interrupt needs it), subscribe the turn queue. -- `receive_turn` yields until `turn/completed` **whatever its status**: - -| codex notification | AgentEvent | -|---|---| -| `item/agentMessage/delta` | `TextDelta` | -| `item/reasoning/textDelta` / `summaryTextDelta` | `ThinkingDelta` | -| `item/started {commandExecution}` | `ToolUse(id, "Bash", {command, cwd})` | -| `item/commandExecution/outputDelta` | `ToolOutputDelta` for the live Bash block; completion still emits the final `ToolResult` | -| `item/started {fileChange}` | per `changes[]` entry: best-effort pre-apply `spec.snapshot(path)` then `ToolUse(f"{itemId}:{n}", "Edit", {file_path: path})` | -| `item/fileChange/patchUpdated` / `item/completed {fileChange}` | per entry: `ToolResult(f"{itemId}:{n}", diff)` — multi-file changes produce N pairs, so the diff panel gets every file | -| `item/started {mcpToolCall}` | `ToolUse(id, "mcp____", input)` | -| `item/mcpToolCall/progress` / `item/completed {mcpToolCall}` | `ToolResult` | -| `item/started|completed {webSearch}` | `ToolUse`/`ToolResult("WebSearch")` | -| `item/plan/delta` + plan items | `SystemEvent("plan_update", ...)` broadcast to the live UI | -| `thread/tokenUsage/updated` (matching turnId) | retained: `last` breakdown + `modelContextWindow` | -| `model/rerouted` | `ModelObserved(new_model)` | -| `turn/completed` | `TurnCompleted(thread_id, turn_id, model, usage=retained tokenUsage → NormalizedUsage, nullable billed cost, explicit cost basis/API-equivalent estimate, duration/context/status/error)` — `interrupted` terminates cleanly | -| generic `error {willRetry}` | `willRetry=true` → `SystemEvent`; else raise `CodexTurnError` into the engine's existing error path | -| unknown | debug log | - -Tool names reuse the Claude vocabulary ("Bash"/"Edit"/"WebSearch"/`mcp__*`) so the -existing UI (tool chips, `_maybe_broadcast_file_changed` diff panel keyed on -`input.file_path`, snapshot-vs-current diffing) works unchanged. Inline -`EditToolBlock` old/new rendering shows the panel path only — acceptable, documented. - -### Approvals / interactive / wakeups - -- Default config: `approval_policy: never` + full-access sandbox → no approval - requests, parity with today. Tightened configs route - `item/commandExecution/requestApproval` / `item/fileChange/requestApproval` / - command/file requests → `InteractionHub.request_approval(...)` - (auto-decline on non-interactive sources, same rule as today) → decision - `{decision: accept|decline}`. `item/tool/requestUserInput` is bridged for - structured options, including auto-resolution timeouts; MCP enum/boolean - forms are bridged while free-form, URL, and secret elicitation is declined. - Permission-profile grants remain explicitly unsupported. -- `interrupt()` → `turn/interrupt {threadId, turnId}`. -- ScheduleWakeup: new nerve-registry tool `schedule_wakeup` (same clamping/semantics, - handler calls `spec.record_wakeup` → identical wakeup rows; the sweep fires the - session with its sticky backend, so no cross-backend hazard per §3). Exposure rules: - ClaudeBackend excludes it (CLI built-in + hook already cover it); the shared HTTP MCP - endpoint **rejects it for satellite (`source="external"`) sessions in the handler** - (engine-run wakeups on never-engine-run sessions make no sense); prompt tool list - (`prompts._format_tool_list`) receives the backend's exclusion set so claude prompts - don't advertise it. - -## 8. Nerve tools for codex sessions — session-bound MCP - -Reuse the production Streamable HTTP endpoint: - -- Per-thread `config.mcp_servers.nerve = {url: "http://127.0.0.1:/mcp/v1/", - bearer_token_env_var: "NERVE_MCP_TOKEN", required: true, startup_timeout_sec: 30, - tool_timeout_sec: 3600}`. - The gateway serves the authenticated MCP ASGI app directly on an ephemeral - plaintext listener bound only to `127.0.0.1`. No upstream HTTP client, TLS - bypass, or public-listener proxy is involved. - `bearer_token_env_var` confirmed supported in 0.144.1 (the existing external-agents - external-agent writer also uses `bearer_token_env_var`; raw credentials never - land in generated Codex TOML. - If per-thread `config.mcp_servers` proves inert in practice (schema allows it), the - fallback is writing `~/.nerve/codex/config.toml` at spawn — same content, still - per-session because the process is per-session (verified by the fake-server test - asserting the chosen mechanism + the real smoke test). -- **External MCP servers parity:** enabled `McpServerConfig` entries are translated - into the same override dict (stdio `command/args/env`; http `url` + - `http_headers`/`bearer_token_env_var`). Untranslatable entries (claude-plugin MCPs — - those ride `options.plugins` on claude) are skipped with a warning. Claude Code - plugins are claude-only by nature; documented in §14. -- **Session binding:** mint an eight-hour per-session JWT with claims - `{nerve_session_id, aud: "nerve-mcp", exp, jti}`; Ultracode children exchange - it for a two-hour worker-scoped token. Auth changes: - `gateway.auth.decode_token` gains an `audience=` passthrough and — because PyJWT - rejects any token carrying `aud` when the caller doesn't request one — - `authenticate_mcp` tries aud-less first, then `aud="nerve-mcp"`; the decoded payload - is **returned to the mount** and stashed for the request so the `ctx_resolver` can - read `nerve_session_id` (today http.py:190 discards it). Claim present → ToolContext - binds the real session id (notify/ask_user/memorize/task_* attribute correctly, the - audit writer keeps working); absent → `SatelliteSessionResolver`, unchanged. -- `ask_user(wait=true)` blocks inside the tool call → works over HTTP MCP with the 1h - `tool_timeout_sec`. -- Codex's `DynamicToolSpec`/`item/tool/call` (client-registered tools, would remove the - HTTP hop) is not referenced from v2 thread/turn params in 0.144.1 → future work. - -## 9. System prompt & prompt-cache strategy - -- Nerve's rendered system prompt → **`developerInstructions`** at thread - start/resume/fork (supported on all three — client rebuilds keep the prompt). - `baseInstructions` (codex harness behavior) untouched. -- Backend appends a `` block (not in prompts.py — it stays neutral): - nerve tools live on the `nerve` MCP server; use `schedule_wakeup` (not - ScheduleWakeup); structured questions and plan updates are bridged, while the - persistent async question primitive remains `mcp__nerve__ask_user`. -- At Nerve's workspace root, AGENTS.md duplicates the rendered identity bundle, - so `project_doc_max_bytes: 0`; explicit nested project cwd keeps ordinary - repository-local AGENTS.md discovery. -- Prompt caching: OpenAI caches automatically (no TTL knob) → `supports_cache_ttl=False` - skips cache_policy for codex sessions; `cachedInputTokens` maps to - `cache_read_input_tokens` in the usage contract so diagnostics stay meaningful. - -## 10. Config, model, auth, cost - -```yaml -agent: - backend: claude # claude | codex — new interactive sessions - cron_backend: null # null → backend (new cron/hook sessions only; wakeups inherit) -codex: - bin_path: codex # PATH-resolved; min version check (>= 0.144) - home_dir: ~/.nerve/codex # isolated CODEX_HOME (auth + config + sessions) - model: gpt-5.6-sol # verified live via model/list (see §17) - cron_model: null # null → codex.model - auth: chatgpt # chatgpt | api_key - api_key: null # or api_key_env: OPENAI_API_KEY - sandbox: danger-full-access # read-only | workspace-write | danger-full-access - approval_policy: never # never | on-request | untrusted (v2 protocol set) - effort_map: {max: xhigh, xhigh: xhigh, high: high, medium: medium, low: low} - web_search: true - tool_timeout_sec: 3600 - turn_idle_timeout_seconds: null # null → agent.cli_idle_timeout_seconds; engine - # resolves into SessionSpec.idle_timeout - pricing: # $/1M tokens; cached input billed at cached rate - gpt-5.6-sol: {input: 5.0, cached_input: 0.5, output: 30.0} # default — MUST have an entry - gpt-5.6-terra: {input: 2.5, cached_input: 0.25, output: 15.0} - gpt-5.6-luna: {input: 1.0, cached_input: 0.1, output: 6.0} -``` - -- **Auth:** isolated `CODEX_HOME` keeps nerve's auth/config/session state away from - the operator's `~/.codex` (which external-agents manages and points back at nerve). - `api_key` → backend runs `account/login/start {type: apiKey}` once per home when - `account/read` says logged-out. `chatgpt` → one-time manual - `CODEX_HOME=~/.nerve/codex codex login`; unauthenticated state surfaces that exact - command in the error. Note: session **title generation** stays on the Anthropic - direct client — in an (unlikely) Anthropic-credential-less deployment titles stay - placeholders; documented, out of scope. -- **DB:** migration `v038_session_backend.py` — `ALTER TABLE sessions ADD COLUMN - backend TEXT` (read default `claude`). Stamped at first client build; resume guard - per §3. -- **Cost accounting:** - - claude (cost_is_cumulative=True): unchanged — `_sdk_cumulative_cost` high-water - mark + `compute_turn_cost` diff + estimate backstop. - - codex API-key auth: `TurnCompleted.total_cost_usd` is per-turn and marked - `api_billed` when the model has a known price. ChatGPT auth records billed - cost as NULL and keeps an explicitly labeled `api_equivalent_estimate`. - The engine passes billed cost directly to `record_turn_usage` and **skips** `compute_turn_cost`, - the `_sdk_cumulative_cost` write, and `_reset_cost_baseline` (all gated on the - capability flag). - - Usage dict: `NormalizedUsage.to_anthropic_shape()` is what lands in - `st.last_usage`, the DB usage row, and the `done` broadcast (§4) — web UI and - cache-ttl split keep working; `raw` retained inside it for diagnostics. - - Context bar: `TurnCompleted.context_window` (codex) overrides the engine's - Anthropic-hardcoded `max_context` (engine.py:2535); claude path unchanged. -- **Observability:** Langfuse instrumentation is claude-SDK-specific - (`configure_claude_agent_sdk`); codex turns produce usage rows + audit events but no - LF traces in v1 — **known gap**, listed in §14. - -## 11. Engine refactor (diff shape) - -1. `nerve/agent/backends/` per §2 (events, base, claude extraction, codex). -2. engine.py: - - `__init__`: build backend registry from config (claude always; codex when - configured); `_session_backends: dict[str, str]` runtime map (SessionManager - keeps storing the bare `AgentClient` — `stop_session`/`shutdown` call - `interrupt()`/`disconnect()` on the protocol object, no tuples). - - `_resolve_backend(session_row, source)` per §3 (sticky-first). - - `_get_or_create_client`: same orchestration (recall freeze, cache_ttl now gated, - interaction hub registration, DB stamps incl. `backend`) ending in - `backend.create_client(spec)`; resume validation via - `backend.validate_resume_target`; `resume_dropped` from codex clears the column. - - `_run_inner`: `client.start_turn(TurnInput(...))` + `async for event in - client.receive_turn()` → `_process_agent_event`; timeout wrapper unchanged - (reads `SessionSpec.idle_timeout`); cancel path persists - `client.native_session_id`. - - `_process_sdk_message` → `_process_agent_event` (same body, event-keyed; - `ModelObserved` feeds `_track_serving_model`). - - `_drain_pending_messages`/`_idle_stream_watcher`: gated on - `supports_idle_stream`; framing logic stays, consuming - `try_receive_idle_event`/`receive_idle_event`. - - Claude-specific helpers removed (moved), thin deprecation re-exports only where - tests need a transition (§12 updates them properly instead where possible). - - Cron/hook call sites stop pre-resolving models (§3). -3. `nerve/mcp_server/auth.py` + `http.py`: audience-aware decode, payload surfaced to - ctx_resolver, session-claim binding (§8). -4. `nerve/agent/tools/`: `schedule_wakeup` handler + registry entry; adapters accept an - exclusion set; HTTP endpoint rejects `schedule_wakeup` for satellite sessions; - `prompts._format_tool_list` takes exclusions. -5. config.py: `AgentConfig.backend/cron_backend` + `CodexConfig` dataclass + - `NerveConfig.codex` + validation (unknown backend → hard error; codex+ollama-model - → error; approval_policy restricted to the v2 set; pricing table must cover - `codex.model`). -6. DB migration `v038_session_backend.py`. -7. Frontend: interaction-type union + `ApprovalBlock` (command/file variants) + - answer routing; `npm run build`. -8. docs: this plan; `docs/architecture.md` engine section; `docs/config.md` codex - section; `docs/sdk-sessions.md` (backend column, sticky resolution, cross-backend - guard). - -## 12. Testing - -New (offline; no real codex binary in CI): - -- `tests/test_backend_events.py` — claude translate_message parity: text/thinking/ - tool-use/tool-result blocks, parent_tool_use_id, ModelObserved gating on sub-agent - messages, ResultMessage → TurnCompleted (usage passthrough, cumulative cost), - ordered_blocks/broadcast outcomes equal to the pre-refactor behavior (reusing - existing engine-test fixtures). -- `tests/test_codex_protocol.py` — notification→event mapping per §7's table (fixtures - shaped from the exported schema): multi-file fileChange fan-out, outputDelta - buffering + exitCode→is_error, tokenUsage retention → NormalizedUsage mapping - (cachedInputTokens→cache_read), turn status completed/interrupted/failed, generic - error willRetry split, unknown-notification tolerance; pricing math incl. cached - tokens and the unknown-model→None rule; usage `to_anthropic_shape` contract. -- `tests/test_codex_appserver.py` — **fake app-server** subprocess - (`tests/fixtures/fake_codex_appserver.py`, scripted JSONL JSON-RPC): handshake, - turn streaming, **async approval round-trip with interleaved deltas** (proves the - reader never blocks — the exact deadlock the official SDK has), interrupt (incl. - response arriving while an approval is pending), `interrupted` turn terminating - receive_turn, resume + fork param shapes, **resume-miss → fresh-start fallback + - resume_dropped signal**, process death mid-turn → transport error → engine retry - path, unknown server-request safe default, mcp_servers/config-override payload - assertion. -- `tests/test_engine_backend_selection.py` — sticky resolution (stored backend beats - config; wakeup on claude session under `cron_backend: codex` stays claude), - new-session config routing + `backend_override`, cross-backend guard clears native - id, `sessions.backend` stamping, cron call sites passing model=None through, - ollama+codex validation error, excluded-tools filtering (claude excludes - schedule_wakeup; prompt tool list respects it). -- `tests/test_mcp_session_binding.py` — aud-less token → satellite path unchanged; - `aud="nerve-mcp"` + session claim → real-session ToolContext; wrong aud → 401; - schedule_wakeup rejected for satellites; audit rows carry the real session id. -- Migration test upsert into the existing migration-suite pattern (v038). -- **Updated (enumerated — mechanical, assertions preserved):** `tests/test_engine.py` - (`_effective_effort`/`_process_sdk_message`/`_track_serving_model`/ - `_sdk_resume_file_exists` move → import from backends.claude / re-keyed on events), - `tests/test_autonomous_turns.py` (drain fixtures move to event-shaped fakes), - `tests/test_cache_policy.py` (`_build_env` import), `tests/test_interactive.py` - (hub split; PermissionResult assertions move to the claude-adapter test). -- Everything else must pass untouched. Full suite green before review. - -Manual/integration (not CI): `scripts/codex_smoke.py` — real app-server: auth check, -one thread, one trivial turn, assert text + usage + thread id + **RSS of the -app-server process** (recorded in this doc §17 before any wide flip), fileChange -`item/started` pre-apply ordering probe (validates the snapshot assumption; if it -fires post-apply, switch snapshots to reverse-applying the received diff — noted in -code where the fallback goes). - -## 13. Risks & mitigations - -| Risk | Mitigation | -|---|---| -| App-server API drift (experimental surface) | min-version check; defensive parsing; schema snapshot marker; smoke script | -| fileChange `item/started` fires post-apply → snapshot captures new content | smoke probe; fallback: reconstruct pre-image by reverse-applying the received unified diff | -| Per-thread `config.mcp_servers` override ignored | fake-server asserts what we send; smoke verifies effect; fallback: config.toml in isolated CODEX_HOME (still per-session) | -| Beta Python SDK temptation | rejected with verified reason (reader-thread approval dispatch); documented | -| Cross-backend resume corruption | sticky backend column + guard; wakeups inherit | -| Per-session JWT | aud-scoped, session-claimed, env-only, eight-hour expiry + unique jti; worker tokens expire in two hours | -| Cost table drift | config-driven; unknown model → cost None; ChatGPT estimates are separate from billed cost | -| App-server RSS per session | measured in smoke before flip; idle sweep already bounds live client count | -| `turn/steer` unused → messages queue during turns | same as today (per-session serialization); steering out of scope v1 | -| Structured input/plan protocol drift | schema contract + focused request/plan/output tests; unknown secret/freeform elicitation declines safely | -| Live instance safety | worktree only; default config unchanged; no restart without operator approval | - -## 14. Out of scope (v1) — explicit non-parity list - -- Langfuse tracing for codex turns (usage rows + audit only). -- **Permission grants** (`item/permissions/requestApproval`): the response - type requires a constructed `GrantedPermissionProfile` with no decline - variant — nerve answers with a JSON-RPC error, which codex treats as - not-granted and continues sandboxed (logged). Command and file-change - approvals ARE supported. -- Concurrent approval cards: the web UI holds one pending interaction at - a time (pre-existing store design); a second simultaneous approval - (possible under `untrusted`) waits server-side until the first resolves - or times out. -- Claude Code plugin MCPs on codex (plugin lifecycle is claude-CLI-owned). -- PDF/document inputs on codex (inline note to the model instead of silent drop). -- Dynamic client-registered tools over app-server; `turn/steer`; `thread/rollback`; - codex hooks system; realtime/voice. -- Mixed-backend forking (guard refuses). -- Codex-side thread naming/archive sync. -- `model_provider` overrides (incl. Ollama-through-codex — validation error). - -## 15. Rollout - -1. Merge with `backend: claude` default → zero behavior change; suite green. -2. Operator: `CODEX_HOME=~/.nerve/codex codex login` (or api_key config); run - `scripts/codex_smoke.py`; record RSS + snapshot-ordering results in §17. -3. A/B: `backend_override` on a fresh session, or `agent.cron_backend: codex` (safe - now — sticky resolution keeps existing sessions and their wakeups on claude). -4. Watch: usage rows (`raw`), cost attribution, audit trail, approval UX. -5. Full flip (`agent.backend: codex`) = config edit + `nerve restart` — the operator's call. - -## 15b. New-chat backend selector (UI, added on request) - -The composer shows a segmented **Claude / Codex** control on new (virtual) -chats: Claude in the brand-orange tint, Codex in teal, tooltips carrying -each backend's default model. The choice binds at server-side session -creation (`POST /api/sessions {backend, model, cwd}` → persisted columns, -validated against the engine's backend registry) and the control -disappears once the conversation starts — persistent backend and model -badges then show what the session runs on. Codex-selected chats hide the -Ollama model picker (its entries can't be served by codex). `GET -/api/models` now advertises `backends: {default, options:[{id,label, -model}]}` for the selector. Visually verified against a scratch gateway -(screenshots: codex-selector-3/4.png in the workspace). - -## 16. Review log - -- v1 → v2 (2026-07-10): adversarial subagent review (agent a4093b396028e149b) found 4 - blockers / 12 majors / 14 minors — all incorporated: JWT audience handling (§8), - `_sdk_resume_file_exists`/`_safe_disconnect` into the seam (§5), sticky backend - resolution (§3), turn-completion contract rewritten around - `thread/tokenUsage/updated` + `turn.status` (§0/§7), ModelObserved event (§4), - usage-shape contract (§4/§10), capability-gated cost bookkeeping + pricing entry for - the default model (§10), SessionManager stores protocol clients (§11), external MCP - translation (§8), cron model call sites (§3), approval frontend in scope (§7), - InteractionHub split (§7), idle-drain contract (§3/§5), no-exp session JWT (§8), - multi-file fileChange fan-out (§7), codex resume-miss recovery (§7), plus all minors - (image conversion, `on-failure` removed, effort-as-string, schedule_wakeup scoping, - timeout plumbing, langfuse/title-gen/PDF notes, context bar, `interrupted` - termination, migration v038, test-file enumeration). - -## 17. Post-implementation verification notes - -**Implemented 2026-07-10** (branch `pufit/codex-backend`). Deviations and -findings vs the plan: - -- **MCP config mechanism:** spawn-level `-c key=value` overrides only (the - per-thread `config` dict path was dropped — process==session makes spawn - scope per-session anyway, and `-c` is the mechanism the official SDKs use). - Asserted by `test_config_overrides_carry_mcp_bridge` against the fake - app-server's argv mirror. -- **Resume-miss recovery** implemented as a `client.resume_dropped` flag - (not the `ResumeDroppedError` carrier exception) — the backend recovers - internally and the engine clears the DB column on the flag. -- **`build_backends` constructs BOTH backends always** (construction is - side-effect-free except the codex-home mkdir): a stored `backend=codex` - session must stay resumable after config flips back to claude. -- **got_content semantics** (drain + retry gating) now key on *content - events* rather than mere AssistantMessage arrival — an empty assistant - message no longer opens/persists an empty autonomous turn (behavior - delta, deliberate; documented in test updates). -- **Failed codex turns** complete the turn with an inline - `⚠️ Turn failed: …` note + `status=failed` in result meta (engine's - transport-death path stays reserved for actual runtime death). -- Offline verification: fake app-server suite (11 tests) proves the - approval round-trip streams deltas while pending (the beta-SDK deadlock - case), interrupt→`turn/completed(interrupted)` terminates `receive_turn`, - transport death raises into the engine retry path, resume-miss falls back - fresh, multi-file fileChange fan-out + pre-apply snapshots, usage - normalization (cached ⊆ input split) and pricing math. -- Full pytest suite green (see PR); frontend `tsc --noEmit` + `npm run - build` clean with the new ApprovalCard. - -**Live verification (2026-07-10, `scripts/codex_smoke.py --auth api_key`, -real app-server + OpenAI API key):** - -- ✅ API-key auth: `account/login/start {type: apiKey}` → threads + turns - work end-to-end. Auth state persisted in `~/.nerve/codex/auth.json`. -- ✅ Real turn on **gpt-5.6-sol**: `SMOKE-OK`, status=completed, - usage in=11013 / out=8, reported context window **353,400 tokens**, - computed cost $0.055 (pricing table math verified against live usage). -- ⚠️→fixed **`gpt-5.6-codex` does not exist.** Live `model/list`: - `gpt-5.6-sol, gpt-5.6-terra, gpt-5.6-luna, gpt-5.5, gpt-5.4, - gpt-5.4-mini, gpt-5.2`. Default `codex.model` corrected to - `gpt-5.6-sol`; the failed-turn path incidentally got a live test and - behaved as designed (status=failed surfaced, transport healthy). -- ✅ **RSS: ~48 MB per app-server process** (connect and after turns) — - cheaper than a Claude CLI subprocess; the idle sweep bounds count as - before. No per-session memory concern. -- ⚠️→fixed **fileChange `item/started` fires POST-apply** on the real - binary (the §13 risk, confirmed live). Implemented the reverse-diff - fallback: `backends/codex/diffs.py::reverse_apply_unified_diff` - reconstructs the pre-image from the change's unified diff - (verification-first — a pre-apply timing fails the reverse and the - disk content is used, so both timings are correct; snapshots defer to - `item/completed` when `item/started` carries no diff yet). Re-probe: - `final='CHANGED' snapshot-time='ORIGINAL'` ✅ — the diff panel gets a - true before/after pair. -- ✅ **`mcp_servers.nerve` exercised live end-to-end:** a real Codex - subprocess connected to the dedicated loopback MCP ASGI listener and - completed `session_context` with session-bound auth. - -**Adversarial review round 2 (2026-07-10, post-implementation subagent -review of the full branch — 3 MAJOR / 9 MINOR / 4 NIT, all addressed or -descoped explicitly):** - -- MAJOR: the Claude generic-error path lost the early-captured resume id - (crashed turns restarted conversations). Fixed: the exception handler - now pulls `client.native_session_id` (like the cancel path) — EXCEPT - for poisoned contexts, which must start fresh (the pre-refactor code - re-persisted the poisoned id there; deliberately not preserved). -- MAJOR: `FileUpdateChange.kind` is a tagged object (`{"type": "add"}`) - in the v2 schema, not a string. Fixed via `_change_kind` normalization; - the fake app-server + tests now emit the schema shape; ApprovalCard - renders both shapes. -- MAJOR: `item/permissions/requestApproval` reply shape was invalid — - descoped to a JSON-RPC-error denial (see §14). -- MINOR fixes: `resume_dropped` no longer un-done by `mark_active` - (stale local id dropped; engine-level regression test added); - elicitation → `{"action": "decline"}` and requestUserInput → - `{"answers": {}}` (schema-correct); legacy execCommandApproval/ - applyPatchApproval aliases removed (nerve never opts into the legacy - API); late `turn/completed` now scoped by turn id; CRLF files - round-trip through the reverse-diff; malformed INACTIVE codex config - can no longer brick startup (lenient coercion + warnings); - `codex.turn_idle_timeout_seconds` actually wired; `model/rerouted` - reads `toModel` (the real field); realtime opt-out method names - corrected; `_last_error` reset per turn; MCP server names validated as - TOML key segments. -- Plan corrections (was overclaiming): ollama+codex is a load-time - WARNING, not an error (the hazard is per-model, guarded by the claude - path's ollama routing); `codex/protocol.py` was folded into - `backend.py`; `tests/test_backend_events.py` coverage lives in - test_engine.py/test_autonomous_turns.py; v038 has no dedicated - migration test (covered by the schema-version suite). - -## 18. Integration-audit remediation and Ultracode (2026-07-11) - -- Migration v039 backfills every legacy NULL backend to `claude`; new sessions - persist backend, model, and cwd immediately. Forks inherit all three, reject - mixed backends, and pass the selected message's native `lastTurnId` to - `thread/fork`. -- Resume fallback is limited to positively identified missing-thread errors. - Authentication, validation, permission, transport, and protocol failures are - surfaced without silently discarding context. -- `GET /api/models` is driven by an authenticated `account/read` + `model/list` - preflight. The UI stores model choice per backend, shows a persistent backend - badge, and never sends an Anthropic/Ollama model to Codex. -- Version support is pinned to `>=0.144.1,<0.145.0`; the canonical generated v2 - schema hash and required RPC methods are checked by - `scripts/check_codex_schema.py`. -- Transport startup failure owns full process/task cleanup. App-server and its - Ultracode descendants share a process group. Notification backlog exhaustion - and >64 MiB JSONL records are explicit terminal transport failures, never - drop-oldest/truncate-and-continue behavior. -- Native thread IDs have a one-to-one mapping to Nerve sessions. Assistant - messages retain native turn IDs so a message-point fork is reproducible; - rollout sync reuses that mapping and cannot archive a Nerve-owned session. -- Optional Ultracode support is managed under the isolated Codex home. Nerve - installs version `0.3.0+codex.20260601143116` at exact commit - `9dde0086e983413016bf62ab96ba6bb17b599fae`, verifies both on preflight, and - forces `ULTRACODE_NO_AUTO_UPDATE=1`. -- A deterministic, hash-verified overlay fails closed on upstream source drift - and hard-caps pilot workflows at concurrency 2, 250k tokens, and eight total - workers by default. It also prevents the upstream skill from overriding - Nerve's dashboard-off policy. -- `CODEX_CLI_PATH` points workers at an owner-only Nerve wrapper. The wrapper - forwards the parent's stable MCP overrides, exchanges the parent session token - for a two-hour `nerve_worker_id` token, and preserves the same parent-session - attribution in tool audit metadata. Worker aggregate usage is imported once - per workflow ID; ChatGPT/API billing semantics remain distinct. -- Interrupt/disconnect terminates the app-server process group, including child - workers. Non-terminal journals under `$CODEX_HOME/ultracode/runs` are listed by - `/api/codex/status` and `nerve codex doctor` for operator recovery. Workers are - read-only by default; writable workflows remain an explicit user choice. From 787431fc14bcecfec2d81e70120fe4daf8f4537b Mon Sep 17 00:00:00 2001 From: pufit Date: Sun, 12 Jul 2026 21:24:00 -0400 Subject: [PATCH 8/8] Clarify Nerve runbook reuse semantics in codex instructions; test prompt isolation --- nerve/agent/backends/codex/backend.py | 13 +++++++++++++ tests/test_codex_protocol.py | 13 +++++++++++-- tests/test_engine.py | 28 ++++++++++++++++++++++++++- 3 files changed, 51 insertions(+), 3 deletions(-) diff --git a/nerve/agent/backends/codex/backend.py b/nerve/agent/backends/codex/backend.py index a5fec9f..09bd1be 100644 --- a/nerve/agent/backends/codex/backend.py +++ b/nerve/agent/backends/codex/backend.py @@ -66,6 +66,19 @@ - Nerve's tools (memorize, memory_recall, task_*, notify, ask_user, skills, schedule_wakeup, ...) are provided by the `nerve` MCP server — call them as `mcp__nerve__` / however your harness names MCP tools. +- Nerve's `mcp__nerve__skill_*` tools expose Nerve runbooks, not Codex-native + skills. Codex's per-turn native-skill rule does not apply to these runbooks. +- A successful `mcp__nerve__skill_get(name)` satisfies the workspace instruction + to read that runbook while its full result remains in the current visible + agent context. Apply it on later user turns without calling `skill_get` again; + a new user turn, reconnect, or resume of the same native thread does not by + itself invalidate the loaded runbook. +- Reload a Nerve runbook only when its full prior result is absent (for example, + in a genuinely fresh native thread or independent child), it was explicitly + updated or invalidated, the user requests a refresh, or context compaction + discarded the needed details. A child or fork that inherited the full result + may reuse it; an independent child must load its own copy once. +- Native Codex skills keep their normal trigger and per-turn behavior. - To schedule a future wakeup of this session, use the `schedule_wakeup` nerve tool (there is no ScheduleWakeup built-in here). - Native structured questions and plan updates are bridged into Nerve's UI. diff --git a/tests/test_codex_protocol.py b/tests/test_codex_protocol.py index ec5ff02..38d51a4 100644 --- a/tests/test_codex_protocol.py +++ b/tests/test_codex_protocol.py @@ -163,8 +163,17 @@ def test_effort_mapping_and_defaults(tmp_path): def test_backend_notes_appended_to_developer_instructions(tmp_path): client = _client(tmp_path) + client._spec.system_prompt = "base system prompt" params = client._backend.thread_params(client._spec) - assert params["developerInstructions"].startswith("") - assert "schedule_wakeup" in params["developerInstructions"] + instructions = params["developerInstructions"] + flat_instructions = " ".join(instructions.split()) + assert instructions.startswith("base system prompt") + assert "schedule_wakeup" in instructions + assert "Nerve runbooks, not Codex-native skills" in flat_instructions + assert "later user turns without calling `skill_get` again" in flat_instructions + assert "resume of the same native thread" in flat_instructions + assert "independent child must load its own copy once" in flat_instructions + assert "context compaction" in flat_instructions + assert "Native Codex skills keep their normal" in flat_instructions assert params["approvalPolicy"] == "never" assert params["sandbox"] == "danger-full-access" diff --git a/tests/test_engine.py b/tests/test_engine.py index 326f1d3..b2ec645 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -12,7 +12,7 @@ from nerve.agent.backends.base import SessionSpec from nerve.agent.backends.claude import ClaudeBackend, ClaudeClient, translate_message from nerve.agent.engine import AgentEngine, _TurnState, _model_family -from nerve.config import AgentConfig +from nerve.config import AgentConfig, NerveConfig @pytest.mark.parametrize( @@ -104,6 +104,32 @@ def test_agent_config_cron_effort_default_and_override(): assert cfg.effort == "max" +def test_claude_system_prompt_excludes_codex_runbook_policy(tmp_path): + """Codex-only runbook semantics must never alter Claude's prompt.""" + cfg = NerveConfig.from_dict({"workspace": str(tmp_path)}) + backend = ClaudeBackend(SimpleNamespace( + config=cfg, + claude_plugins=lambda: [], + )) + marker = "exact claude system prompt" + spec = SessionSpec( + session_id="claude-prompt-isolation", + source="web", + model=cfg.agent.model, + effort="high", + system_prompt=marker, + cwd=str(tmp_path), + ) + + with patch.object(backend, "_build_mcp_servers", return_value={}), \ + patch.object(backend, "_build_hooks", return_value={}): + options = backend._build_options(spec) + + assert options.system_prompt == marker + assert "Nerve runbooks" not in str(options.system_prompt) + assert "Codex-native skills" not in str(options.system_prompt) + + # --------------------------------------------------------------------------- # ClaudeClient.receive_turn — per-message idle timeout (hung-CLI detection) # ---------------------------------------------------------------------------