From 988b868cfeea7981d68c4e42904f1bd7489a7342 Mon Sep 17 00:00:00 2001 From: Eric Juta Date: Thu, 26 Mar 2026 12:58:16 +0000 Subject: [PATCH 01/45] docs: add Claude Code hooks parity spec\n\nCo-authored-by: Codex --- docs/claude-code-hooks-parity.md | 275 +++++++++++++++++++++++++++++++ 1 file changed, 275 insertions(+) create mode 100644 docs/claude-code-hooks-parity.md diff --git a/docs/claude-code-hooks-parity.md b/docs/claude-code-hooks-parity.md new file mode 100644 index 000000000..1094e6cf2 --- /dev/null +++ b/docs/claude-code-hooks-parity.md @@ -0,0 +1,275 @@ +# Claude Code hooks parity + +This document captures the current Codex hooks surface and the remaining +feature-parity gap versus Claude Code's documented hooks system. It is intended +to be the canonical planning doc for expanding `codex_hooks`. + +## Goal + +Bring Codex's public `hooks.json` lifecycle hooks close enough to Claude +Code's model that Claude-oriented hook setups can be ported with predictable, +documented edits rather than custom runtime patches. + +This does not require byte-for-byte compatibility in one step. It does require: + +- matching the major public event categories users expect, +- supporting the handler types those configurations rely on, +- honoring documented decision-control fields when they are accepted by schema, +- documenting any intentional deltas that remain. + +## Current Codex surface + +Today Codex exposes five public `hooks.json` event groups: + +- `PreToolUse` +- `PostToolUse` +- `SessionStart` +- `UserPromptSubmit` +- `Stop` + +The current engine only executes synchronous command handlers. `prompt`, +`agent`, and `async` configurations are parsed but skipped with warnings. + +The current runtime also has narrower execution coverage than Claude Code: + +- `PreToolUse` and `PostToolUse` are currently wired through the shell path, + with runtime requests using `tool_name: "Bash"`. +- `UserPromptSubmit` and `Stop` ignore matchers. +- some wire fields are present in schema but are rejected by the output parser + as unsupported. + +Legacy internal paths still exist for notification-style hooks +(`AfterAgent` / deprecated `AfterToolUse`), but they are not part of the +public `hooks.json` contract. + +## Claude Code parity gap + +Claude Code's current hooks reference documents a larger event surface and more +handler modes than Codex currently supports. + +### Missing event coverage + +Codex does not yet expose public `hooks.json` support for these documented +Claude Code event families: + +- `InstructionsLoaded` +- `PermissionRequest` +- `PostToolUseFailure` +- `Notification` +- `SubagentStart` +- `SubagentStop` +- `StopFailure` +- `TeammateIdle` +- `TaskCompleted` +- `ConfigChange` +- `CwdChanged` +- `FileChanged` +- `WorktreeCreate` +- `WorktreeRemove` +- `PreCompact` +- `PostCompact` +- `SessionEnd` +- `Elicitation` +- `ElicitationResult` + +### Missing handler coverage + +Codex does not yet support these Claude Code hook handler categories in the +public engine: + +- async command hooks, +- HTTP hooks, +- prompt hooks, +- agent hooks. + +### Partial decision-control coverage + +Codex schema already models some advanced fields, but runtime support is still +partial: + +- `PreToolUse.updatedInput` is rejected. +- `PreToolUse.additionalContext` is rejected. +- `PreToolUse.permissionDecision: allow` is rejected. +- `PreToolUse.permissionDecision: ask` is rejected. +- `PostToolUse.updatedMCPToolOutput` is rejected. +- `suppressOutput` is rejected for `PreToolUse` and `PostToolUse`. +- `stopReason` and `continue: false` are rejected for `PreToolUse`. + +This creates a confusing state where the schema shape suggests broader support +than the runtime actually honors. + +### Tool and matcher parity gaps + +- `PreToolUse` and `PostToolUse` should evolve from shell-centric wiring to + a consistent tool-event contract across relevant tool classes. +- matcher support should be explicit and consistent across all events that + Claude users expect to filter. +- MCP-aware hook behavior should be designed as first-class runtime behavior, + not as a schema placeholder. + +## Non-goals + +- Reproducing Claude Code internals exactly where Codex architecture differs. +- Preserving every existing partial or deprecated behavior forever. +- Adding public hook types without app-server, TUI, and docs visibility for the + resulting runs. + +## Design principles + +- **Public contract first**: do not expose schema fields that the runtime will + immediately reject unless they are clearly marked unsupported. +- **Event completeness over aliases**: add real lifecycle events before adding + compatibility shims. +- **One event, one payload contract**: every public event needs stable input and + output schema fixtures, runtime execution, and surfaced hook-run reporting. +- **Fail-open unless explicitly blocking**: invalid hook output should not cause + surprising hard failures outside events whose contract is intentionally + blocking. +- **No hidden UI drift**: hook additions must be visible in the TUI and + app-server surfaces anywhere hook runs are rendered today. + +## Implementation plan + +### Phase 1: make the current public surface coherent + +Goal: remove misleading partial support inside the existing five events. + +Required work: + +- align schema and parser behavior for the five existing events, +- either implement or remove unsupported schema fields that are already emitted + in fixtures, +- document matcher behavior explicitly, +- document current shell-centric tool coverage explicitly, +- add a dedicated user-facing reference doc for `hooks.json` behavior if the + main docs site still only mentions legacy notification hooks. + +Acceptance: + +- no schema field is silently accepted but runtime-rejected without explicit + documentation, +- the docs explain exactly which event fields and decisions are live, +- existing five-event behavior is covered by tests and schema fixtures. + +### Phase 2: expand event coverage on the existing command-hook engine + +Goal: add missing lifecycle events before broadening handler types. + +Priority order: + +1. `PermissionRequest` +2. `Notification` +3. `SubagentStart` and `SubagentStop` +4. `PostToolUseFailure` and `StopFailure` +5. `SessionEnd` +6. `ConfigChange`, `CwdChanged`, and `FileChanged` +7. `PreCompact` and `PostCompact` +8. `TaskCompleted` and `TeammateIdle` +9. `InstructionsLoaded` +10. `WorktreeCreate` and `WorktreeRemove` +11. `Elicitation` and `ElicitationResult` + +Acceptance: + +- each event has an input schema fixture, +- each event has runtime dispatch wiring, +- each event emits `HookStarted` and `HookCompleted` consistently, +- each event has an explicit matcher story, +- docs list the event as supported. + +### Phase 3: broaden handler types + +Goal: match the main Claude Code hook execution modes. + +Required work: + +- implement async command hooks, +- add HTTP hook handlers, +- add prompt hook handlers, +- add agent hook handlers, +- surface handler type and execution mode accurately in run summaries. + +Acceptance: + +- discovery no longer skips supported handler types with warnings, +- `HookRunSummary` reports real handler type and execution mode, +- command, HTTP, prompt, and agent handlers have stable input/output contracts, +- async execution semantics are documented, especially ordering and failure + behavior. + +### Phase 4: close decision-control parity gaps + +Goal: implement or explicitly drop advanced output fields. + +Required work: + +- decide whether `PreToolUse.updatedInput` will be supported in Codex, +- decide whether `PreToolUse.permissionDecision: ask` maps to an approval + prompt, a model-visible continuation, or remains unsupported, +- implement `additionalContext` anywhere the contract claims it exists, +- decide whether `PostToolUse.updatedMCPToolOutput` is part of the public + runtime contract, +- review event-specific `continue`, `stopReason`, and `suppressOutput` + semantics for consistency. + +Acceptance: + +- advanced hook output fields are either implemented end-to-end or removed from + public schema, +- runtime behavior matches docs and tests, +- no event-specific decision-control behavior relies on undocumented parser + special cases. + +### Phase 5: tool-class parity for pre/post tool hooks + +Goal: make tool hooks genuinely tool-aware rather than shell-specific. + +Required work: + +- define which Codex tool classes participate in `PreToolUse` and + `PostToolUse`, +- expose stable tool identifiers and input payloads for those classes, +- define MCP-tool matcher behavior explicitly, +- preserve backward compatibility for existing Bash-oriented hooks where + feasible. + +Acceptance: + +- users can target more than the shell path with pre/post tool hooks, +- tool names and payloads are documented and stable, +- MCP tool behavior is implemented rather than placeholder-only. + +## Required cross-cutting work + +- update docs under `docs/` when public behavior changes, +- keep generated schema fixtures in sync, +- extend TUI and app-server visibility for new hook events when needed, +- add focused tests for parser behavior, discovery behavior, and runtime + dispatch, +- decide whether legacy notification hooks remain supported long term or are + explicitly deprecated in docs. + +## Open decisions + +- Should Codex aim for Claude-compatible field names and semantics wherever + possible, or only for event-name parity? +- Should prompt and agent hooks be first-class in the initial public contract, + or stay experimental behind feature flags after implementation? +- Should unsupported advanced fields be removed now to reduce confusion, or kept + in schema as forward-compatibility placeholders? +- Which events should be thread-scoped versus turn-scoped in app-server and TUI + reporting? + +## Recommended first implementation slice + +If this work is started incrementally, the highest-leverage first slice is: + +1. publish a real user-facing hooks reference for Codex, +2. make the existing five events internally coherent, +3. add `PermissionRequest`, `Notification`, `SubagentStart`, + `SubagentStop`, and `SessionEnd`, +4. then add async and HTTP handler support. + +That sequence closes the largest user-visible parity gaps without mixing event +expansion, execution-model expansion, and advanced mutation semantics into one +hard-to-review change. From 3539af39303876c5c1a3d2ec3996a44b0f2aae32 Mon Sep 17 00:00:00 2001 From: Eric Juta Date: Thu, 26 Mar 2026 12:59:55 +0000 Subject: [PATCH 02/45] docs: tighten hooks parity handoff\n\nCo-authored-by: Codex --- docs/claude-code-hooks-parity.md | 71 ++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/docs/claude-code-hooks-parity.md b/docs/claude-code-hooks-parity.md index 1094e6cf2..02c3dd7ae 100644 --- a/docs/claude-code-hooks-parity.md +++ b/docs/claude-code-hooks-parity.md @@ -17,6 +17,40 @@ This does not require byte-for-byte compatibility in one step. It does require: - honoring documented decision-control fields when they are accepted by schema, - documenting any intentional deltas that remain. +## Read order + +If you are implementing against this doc, read the current source in this order: + +1. `docs/claude-code-hooks-parity.md` +2. `codex-rs/hooks/src/engine/config.rs` +3. `codex-rs/hooks/src/engine/discovery.rs` +4. `codex-rs/hooks/src/schema.rs` +5. `codex-rs/hooks/src/engine/output_parser.rs` +6. `codex-rs/core/src/hook_runtime.rs` +7. `codex-rs/core/src/codex.rs` +8. `codex-rs/core/src/tools/registry.rs` + +This order moves from public contract to discovery, then schema, then parser, +then runtime wiring, then legacy behavior. + +## Current source snapshot + +This doc is based on the current implementation shape in this checkout: + +- public `hooks.json` event groups are defined in + `codex-rs/hooks/src/engine/config.rs`, +- handler discovery and unsupported-handler warnings live in + `codex-rs/hooks/src/engine/discovery.rs`, +- public wire schema lives in `codex-rs/hooks/src/schema.rs`, +- output acceptance and rejection behavior lives in + `codex-rs/hooks/src/engine/output_parser.rs`, +- runtime dispatch for start, prompt-submit, pre-tool, and post-tool hooks + lives in `codex-rs/core/src/hook_runtime.rs`, +- `Stop` hook wiring lives in `codex-rs/core/src/codex.rs`, +- deprecated legacy `AfterToolUse` dispatch still exists in + `codex-rs/core/src/tools/registry.rs`, +- no repository-local `hooks.json` files are checked into this tree today. + ## Current Codex surface Today Codex exposes five public `hooks.json` event groups: @@ -128,8 +162,34 @@ than the runtime actually honors. - **No hidden UI drift**: hook additions must be visible in the TUI and app-server surfaces anywhere hook runs are rendered today. +## Do not do + +- Do not add a new public event without input schema, runtime dispatch, + hook-run reporting, and docs in the same lane. +- Do not keep wire fields in public schema as if they are live when the parser + still rejects them. +- Do not use deprecated `AfterAgent` or legacy `AfterToolUse` internals as + the long-term public parity path. +- Do not widen event coverage while leaving handler type and execution mode + reporting misleading in run summaries. +- Do not make hook support TUI-only; app-server and protocol surfaces must stay + aligned. + ## Implementation plan +### Branch and PR order + +Prefer this implementation order: + +1. contract cleanup for the existing five events, +2. runtime event expansion on the command-hook engine, +3. handler-type and execution-mode expansion, +4. advanced decision-control support, +5. pre/post tool-class parity work, +6. final doc consolidation and examples. + +Do not mix all six into one change. Keep each lane reviewable. + ### Phase 1: make the current public surface coherent Goal: remove misleading partial support inside the existing five events. @@ -249,6 +309,17 @@ Acceptance: - decide whether legacy notification hooks remain supported long term or are explicitly deprecated in docs. +## Acceptance gates for any implementation PR + +Every parity PR should satisfy all of these before merge: + +- docs updated for the newly supported behavior, +- generated hook schema fixtures updated if the public schema changed, +- focused tests added or updated for discovery, parser, and runtime behavior, +- hook run summaries still render correctly in TUI and app-server surfaces, +- unsupported behavior is either removed from schema or clearly documented as + unsupported. + ## Open decisions - Should Codex aim for Claude-compatible field names and semantics wherever From 40774ed67d691220a1c02b1fa50ab13d09239ca3 Mon Sep 17 00:00:00 2001 From: Eric Juta Date: Thu, 26 Mar 2026 13:19:14 +0000 Subject: [PATCH 03/45] docs: add agentmemory replacement spec\n\nCo-authored-by: Codex --- ...entmemory-codex-memory-replacement-spec.md | 672 ++++++++++++++++++ 1 file changed, 672 insertions(+) create mode 100644 docs/agentmemory-codex-memory-replacement-spec.md diff --git a/docs/agentmemory-codex-memory-replacement-spec.md b/docs/agentmemory-codex-memory-replacement-spec.md new file mode 100644 index 000000000..72cd811a0 --- /dev/null +++ b/docs/agentmemory-codex-memory-replacement-spec.md @@ -0,0 +1,672 @@ +# agentmemory replacement spec for Codex native memory + +This document evaluates whether a forked Codex should disable the current +first-party memory system and replace it with +`~/Projects/agentmemory` as the primary memory engine. + +It is intended to be the canonical decision and implementation handoff for this +specific question: + +- is `agentmemory` materially more capable than Codex native memory, +- is it likely more token-efficient over time, +- if so, what would be lost by replacing Codex native memory, +- what replacement shape is worth building in a fork. + +This is an architecture and product-integration spec, not a request to +implement the replacement immediately. + +## Executive summary + +`agentmemory` is materially more advanced than Codex native memory as a +capture and retrieval engine. + +The strongest deltas are: + +- broader lifecycle capture through a wider hook surface, +- hybrid retrieval (BM25 + vector + graph), +- pluggable embeddings including Gemini, +- cross-agent MCP and REST exposure, +- retrieval bounded by top-K / token-budgeted context instead of relying on a + prebuilt local memory summary alone. + +Codex native memory is still stronger in first-party runtime integration: + +- startup memory generation is built into the core session lifecycle, +- memory artifacts are deeply integrated into prompt construction, +- memory citations already flow through protocol and app-server surfaces, +- there are explicit local operations for memory refresh and memory removal, +- native memory state includes thread-level `memory_mode` semantics such as + `disabled` and `polluted`. + +Conclusion: + +- `agentmemory` is a material capability superset for memory retrieval and + capture quality. +- It is not a strict end-to-end product superset of Codex native memory. +- A replacement is defensible, but only if the fork rebuilds a thin Codex + integration layer for the native semantics that matter. + +Recommended direction: + +- do not pursue "full Claude parity first", +- do pursue "agentmemory as the primary memory engine with Codex-specific + shims", +- disable Codex native memory generation only after startup injection, + replacement memory ops, and a clear citation story are decided. + +## Target end state + +The target end state is not "agentmemory instead of Codex" in a narrow sense. +The target end state is: + +- \`agentmemory\` is the primary memory engine, +- Codex-native memory generation and consolidation are disabled, +- Codex retains or rebuilds only the product-level semantics that still add + value, +- the fork presents one coherent memory system to users, +- the resulting system is a functional superset of both: + - \`agentmemory\` capture and retrieval strengths, + - Codex-native prompt/runtime/protocol integration where it materially helps. + +In other words, the desired architecture is a Venn-diagram merge with one +authoritative engine, not permanent coexistence of two competing memory stacks. + +## Scope + +This spec compares: + +- `/private/tmp/codex` +- `/Users/ericjuta/Projects/agentmemory` + +This spec is based on the current implementation shape in those checkouts, +including user-local plugin and hook configuration files present in the +`agentmemory` repo. + +## Read order + +Read these sources in order if implementing against this spec: + +1. `docs/agentmemory-codex-memory-replacement-spec.md` +2. `docs/claude-code-hooks-parity.md` +3. `codex-rs/core/src/memories/README.md` +4. `codex-rs/core/src/memories/prompts.rs` +5. `codex-rs/core/templates/memories/read_path.md` +6. `codex-rs/core/src/codex.rs` +7. `codex-rs/hooks/src/engine/config.rs` +8. `codex-rs/hooks/src/engine/discovery.rs` +9. `plugin/hooks/hooks.json` in `agentmemory` +10. `src/hooks/*.ts` in `agentmemory` +11. `src/providers/embedding/*.ts` and `src/state/hybrid-search.ts` in + `agentmemory` +12. `README.md` and `benchmark/*.md` in `agentmemory` + +## Current source snapshot + +### Codex + +Codex currently has: + +- a first-party startup memory pipeline in + `codex-rs/core/src/memories/README.md`, +- phase-1 extraction and phase-2 consolidation into `MEMORY.md`, + `memory_summary.md`, and rollout summary artifacts, +- developer-prompt injection of memory read-path instructions built from + `memory_summary.md`, +- protocol-level memory citations, +- memory-management operations such as `UpdateMemories` and + `DropMemories`, +- thread-level memory-mode state such as `disabled` and `polluted`, +- an under-development `codex_hooks` feature with five public hook events. + +### agentmemory + +The `agentmemory` checkout currently contains: + +- a plugin manifest in `plugin/plugin.json`, +- a Claude-oriented hook bundle in `plugin/hooks/hooks.json`, +- TypeScript hook entrypoints under `src/hooks/`, +- multiple embedding providers under `src/providers/embedding/`, +- hybrid retrieval under `src/state/hybrid-search.ts`, +- REST and MCP surfaces, +- benchmarking and retrieval claims in `README.md` and `benchmark/`. + +The local `agentmemory` checkout is currently dirty. This matters only as a +reminder not to treat the local repo state as release-tagged truth; the source +shape is still adequate for architectural comparison. + +## Codex native memory: what it is + +Codex native memory is a core-managed memory pipeline, not just a retrieval +plugin. + +### Pipeline shape + +Codex native memory runs in two phases: + +1. Phase 1 extracts structured memory from eligible rollouts and stores + stage-1 outputs in the state DB. +2. Phase 2 consolidates those stage-1 outputs into durable memory artifacts on + disk and spawns an internal consolidation subagent. + +This is documented in `codex-rs/core/src/memories/README.md`. + +### Prompt integration + +Codex adds memory usage instructions directly into developer instructions when: + +- the memory feature is enabled, +- `config.memories.use_memories` is true, +- memory summary content exists. + +This is wired in `codex-rs/core/src/codex.rs` via +`build_memory_tool_developer_instructions(...)`. + +### Artifact model + +Codex memory produces and maintains: + +- `memory_summary.md` +- `MEMORY.md` +- `raw_memories.md` +- `rollout_summaries/*` +- optional `skills/*` + +These artifacts are not just storage. They are part of how Codex routes future +memory reads and citations. + +### Operational integration + +Codex exposes native memory operations: + +- `UpdateMemories` +- `DropMemories` + +and memory-state controls: + +- `generate_memories` +- `use_memories` +- `no_memories_if_mcp_or_web_search` + +Codex also tracks thread memory-mode transitions such as `polluted`. + +### Citation integration + +Codex has protocol and app-server support for structured memory citations. +Those citations are already part of assistant-message rendering and transport. + +## agentmemory: what it is + +`agentmemory` is not just a memory file or summary generator. It is a +capture, indexing, retrieval, consolidation, MCP, and REST system. + +### Capture model + +The working Claude-oriented setup uses 12 hooks: + +- `SessionStart` +- `UserPromptSubmit` +- `PreToolUse` +- `PostToolUse` +- `PostToolUseFailure` +- `PreCompact` +- `SubagentStart` +- `SubagentStop` +- `Notification` +- `TaskCompleted` +- `Stop` +- `SessionEnd` + +The hook bundle is defined in `plugin/hooks/hooks.json`. + +### Observation flow + +The core runtime pattern is: + +- hooks send observations to REST endpoints, +- observations are deduplicated and privacy-filtered, +- observations are compressed and indexed, +- retrieval returns bounded context back into future sessions. + +The important thing is that capture happens at many lifecycle points, not just +after a Codex-style rollout completes. + +### Retrieval model + +agentmemory uses: + +- BM25, +- vector retrieval, +- graph retrieval, +- Reciprocal Rank Fusion, +- session diversification, +- progressive disclosure. + +This is a genuine retrieval stack, not just a durable handbook. + +### Embeddings + +agentmemory supports multiple embedding providers, including: + +- local embeddings, +- Gemini embeddings, +- OpenAI embeddings, +- Voyage, +- Cohere, +- OpenRouter. + +Gemini embedding support is real in this checkout, not hypothetical. + +### Cross-agent model + +agentmemory is designed as a shared external service: + +- Claude hooks can write to it, +- MCP clients can query it, +- REST clients can integrate with it, +- multiple agent products can share one instance. + +This is a major design difference from Codex native memory. + +## Capability comparison + +### Capture breadth + +Codex native memory: + +- captures memory from rollouts selected by startup pipeline rules, +- is optimized around per-rollout extraction and later consolidation, +- does not expose comparable public lifecycle capture breadth in the current + hook surface. + +agentmemory: + +- captures at many lifecycle points, +- can record prompts, tool usage, failures, compaction moments, and subagent + lifecycle events, +- better matches the event stream of real coding work. + +Verdict: + +- `agentmemory` is materially stronger. + +### Retrieval quality + +Codex native memory: + +- primarily relies on generated memory artifacts, +- injects a read-path and memory summary into the prompt, +- does not show comparable semantic retrieval, vector search, BM25 ranking, or + graph traversal in the native memory path from the current source scan. + +agentmemory: + +- provides hybrid search, +- supports embeddings, +- supports graph-aware retrieval, +- uses token-bounded context assembly. + +Verdict: + +- `agentmemory` is materially stronger. + +### Consolidation sophistication + +Codex native memory: + +- has a robust two-phase extraction and consolidation pipeline, +- uses a dedicated consolidation subagent, +- maintains curated memory artifacts intended for future prompt routing. + +agentmemory: + +- claims 4-tier consolidation and memory evolution, +- versioning, semantic/procedural layers, relation graphs, and cascading + staleness. + +Verdict: + +- `agentmemory` is likely more ambitious and broader, +- Codex native memory is more tightly integrated and operationally simpler + inside Codex. + +### First-party runtime integration + +Codex native memory: + +- is already first-party, +- already has prompt integration, +- already has memory commands, +- already has citations, +- already participates in internal policy/state flows. + +agentmemory: + +- does not automatically provide those Codex-native product behaviors, +- would need a Codex-specific bridge layer to replace them cleanly. + +Verdict: + +- Codex native memory is stronger here. + +### Cross-agent reuse + +Codex native memory: + +- is local to Codex runtime and artifacts. + +agentmemory: + +- is designed for multi-agent reuse through MCP and REST. + +Verdict: + +- `agentmemory` is materially stronger. + +## Is agentmemory a material superset? + +### Yes, in these senses + +agentmemory is a material superset of Codex native memory for: + +- retrieval breadth, +- semantic search, +- embedding-backed lookup, +- graph-backed lookup, +- cross-agent sharing, +- hook-based observation capture. + +### No, in these senses + +agentmemory is not a strict product-level superset of Codex native memory for: + +- first-party startup prompt integration, +- native memory operations (`UpdateMemories`, `DropMemories`), +- native memory citation protocol plumbing, +- thread-level memory-mode semantics such as `polluted`, +- deep alignment with Codex’s state DB and app-server/TUI surfaces. + +The correct judgment is: + +- `agentmemory` is a material capability superset for retrieval and capture, +- not a strict end-to-end replacement unless shims are added. + +The desired fork outcome therefore is: + +- replace Codex native memory internals, +- preserve or rebuild the useful Codex-native user-facing semantics as shims, +- end with a product-level superset even though `agentmemory` alone is not a + strict superset today. + +## Token efficiency + +This is the strongest practical argument in favor of `agentmemory`. + +### Strong evidence in favor of agentmemory + +The `agentmemory` repo explicitly claims and benchmarks token savings: + +- `~1,900` tokens instead of loading all memory into context in + `README.md`, +- `92%` savings in `benchmark/REAL-EMBEDDINGS.md`, +- `86%` savings in `benchmark/QUALITY.md`, +- essentially corpus-size-stable top-K retrieval in `benchmark/SCALE.md`. + +The architectural reason is coherent: + +- retrieval returns top-K results, +- context assembly is bounded, +- compact result-first progressive disclosure reduces unnecessary expansion. + +### Codex native memory token profile + +Codex native memory is not obviously awful on tokens, but it is shaped +differently: + +- `memory_summary.md` injection is truncated to `5,000` tokens in + `codex-rs/core/src/memories/mod.rs`, +- stage-1 rollout processing can consume large inputs because it is an offline + extraction pipeline, not a lightweight query-time retrieval layer, +- the memory read-path instructs the model to query local memory artifacts + rather than receiving a purpose-built top-K retrieval result from a hybrid + search engine. + +### Apples-to-oranges caution + +The token comparison is not perfectly head-to-head. + +agentmemory benchmarks compare against "load everything into context" and +built-in-memory patterns such as monolithic `CLAUDE.md`-style memory files. +Codex native memory is more curated than that: + +- it injects a bounded `memory_summary.md`, +- it exposes a read-path for progressive on-disk lookup, +- it does not appear to simply dump all historical memory into every turn. + +So it would be wrong to claim the benchmark proves "agentmemory is 92% more +token-efficient than Codex native memory" as a verified current fact. + +### Bottom-line token judgment + +Even with that caveat, `agentmemory` is still likely more token-efficient over +the long term than Codex native memory for large corpora because: + +- query-time retrieval is explicitly bounded, +- corpus growth does not force proportional prompt growth, +- embedding + hybrid retrieval reduces the need to over-inject summaries "just + in case", +- progressive disclosure lets the system fetch more only when needed. + +Codex native memory likely remains acceptable for modest corpus sizes, but it +does not appear to have the same query-time retrieval efficiency model. + +## Replacement architecture + +### Option 1: hard replacement + +Disable Codex native memory generation and injection entirely. Make +`agentmemory` the only memory engine. + +Benefits: + +- cleaner mental model, +- no duplicate memory systems, +- retrieval quality and token efficiency become `agentmemory`-driven, +- cross-agent memory reuse becomes first-class. + +Costs: + +- must rebuild startup prompt integration, +- must replace or remove `UpdateMemories` and `DropMemories`, +- must decide what to do about native memory citations, +- must replace or drop `polluted`/thread memory-mode semantics, +- must extend Codex hooks enough to make capture quality acceptable. + +Risk: + +- highest. + +## Native Codex behaviors that replacement must preserve or intentionally drop + +### Must preserve or replace + +- startup injection into developer instructions, +- user-facing operations to refresh or clear memory state, +- some citation strategy if memory provenance is important, +- protocol/app-server awareness of whatever replaces native memory, +- a clear policy for memory invalidation / pollution. + +### Safe to drop if explicitly accepted + +- on-disk `MEMORY.md` / `memory_summary.md` artifact format compatibility, +- the exact current phase-1 / phase-2 internal implementation, +- native Codex consolidation subagent if `agentmemory` becomes authoritative, +- native artifact grooming and rollout summary persistence if the fork no longer + treats those as the canonical memory store. + +## Key risks + +### Duplicate system ambiguity + +If both systems remain partially active, it becomes unclear: + +- which system is authoritative, +- which one should inject context, +- which one should be cited, +- which one should be cleared by a user-facing "drop memories" action. + +Avoid this. + +### Hook-surface insufficiency + +Current Codex hooks are not enough to reproduce Claude-style `agentmemory` +capture quality: + +- only five public events, +- sync command handlers only, +- narrower tool coverage, +- missing public equivalents for several useful lifecycle events. + +If the fork does not extend hooks, the replacement will still leave value on +the table. + +### Protocol and UX regressions + +Dropping native Codex memory without replacing its protocol-level behaviors can +regress: + +- assistant memory citations, +- memory-management commands, +- app-server/TUI expectations around memory-aware behavior. + +### Benchmark over-claiming + +Do not claim: + +- that the `agentmemory` benchmarks directly prove a specific percentage gain + over Codex native memory, +- or that Gemini embeddings alone guarantee better results. + +The right claim is narrower: + +- `agentmemory` has a more scalable retrieval architecture and published token + savings versus all-in-context memory loading approaches, +- and that architecture is likely better long-term than Codex native memory for + large memory corpora. + +## Recommendation + +Target hard replacement as the end state. + +That means: + +1. make `agentmemory` the sole authoritative memory engine, +2. disable Codex native memory generation and consolidation in the final + architecture, +3. rebuild only the Codex-native product semantics worth preserving as shims on + top of `agentmemory`, +4. remove or deprecate native Codex memory artifacts and workflows in the fork + once those shims exist. + +This is the recommended path because it matches the explicit desired outcome: + +- one memory authority, +- no split-brain behavior, +- `agentmemory` for the stronger retrieval and capture substrate, +- Codex integration retained only where it improves the product. + +The fork can still phase the work, but every phase should point toward hard +replacement rather than toward permanent coexistence. + +## Recommended implementation phases + +### Phase 1: decision and contract + +- Decide that `agentmemory` is the primary memory authority. +- Freeze which native Codex behaviors will be preserved. +- Define how startup context injection will work in the fork. +- Decide whether native memory citations remain required. +- Define the end-state explicitly as a functional superset, not a partial port. + +### Phase 2: Codex integration adapter + +- Add a Codex-specific `agentmemory` integration layer. +- Replace startup memory prompt generation with `agentmemory` retrieval. +- Add equivalent user-facing operations for refresh and clear. +- Decide whether these call into `agentmemory` REST/MCP or a local adapter. + +### Phase 3: hook expansion + +- Extend Codex hook coverage enough to support useful `agentmemory` capture. +- Minimum likely useful additions: + - `SessionEnd` + - `PostToolUseFailure` + - `SubagentStart` + - `SubagentStop` + - `Notification` + - `PreCompact` + +### Phase 4: native memory deprecation + +- Turn off Codex native memory generation by default in the fork. +- Remove or quarantine old native memory artifacts once the adapter is stable. +- Preserve migration tooling only if existing users need it. + +### Phase 5: superset hardening + +- Verify that every retained Codex-native memory affordance has an + `agentmemory`-backed implementation or an intentional deletion note. +- Verify that token usage remains bounded as corpus size grows. +- Verify that there is only one authoritative memory source in the runtime. +- Remove any remaining code paths that can accidentally re-enable split-brain + behavior. + +### Phase 6: optional advanced alignment + +- Add memory citation mapping from `agentmemory` results into Codex protocol + structures. +- Add richer protocol and app-server visibility if needed. +- Reassess whether any remaining native memory logic should survive. + +## Do not do + +- Do not run Codex native memory injection and `agentmemory` injection as + equal peers long term. +- Do not claim a strict superset without rebuilding missing Codex-native + semantics. +- Do not clone Claude plugin infrastructure into Codex just to make the + replacement work. +- Do not overfit to Claude-specific bridge behavior such as + `~/.claude/projects/*/memory/MEMORY.md` if Codex is becoming the primary + target. +- Do not remove native memory citations or memory operations accidentally; if + they are dropped, document that as an intentional product change. + +## Acceptance criteria for a forked replacement + +The replacement is successful only if all of these are true: + +- `agentmemory` is the authoritative source for retrieved memory context, +- Codex native memory is no longer an independent competing authority, +- Codex startup injection still works reliably, +- memory refresh and memory clearing remain user-visible operations or are + intentionally removed with docs, +- hook/event coverage is sufficient to produce materially useful observations, +- token usage stays bounded as the corpus grows, +- the fork has a clear provenance story for memory-derived output, +- there is no ambiguity about which memory system is active, +- the resulting user-facing behavior is a practical superset of the two source + systems rather than a regression-heavy swap. + +## Final judgment + +If the question is "is `agentmemory` materially more advanced than Codex +native memory?", the answer is yes. + +If the question is "should a fork disable Codex native memory and replace it +with `agentmemory`?", the answer is: + +- yes, +- with the condition that the fork also rebuild the Codex-native integration + semantics that matter, +- and with the explicit goal of a single authoritative memory system rather + than a permanent hybrid. From 6c5094f86ed22d91f53fbe053125599c47177e7e Mon Sep 17 00:00:00 2001 From: Eric Juta Date: Thu, 26 Mar 2026 13:24:33 +0000 Subject: [PATCH 04/45] docs: tighten agentmemory replacement target\n\nCo-authored-by: Codex --- ...entmemory-codex-memory-replacement-spec.md | 79 +++++++++++++++++-- 1 file changed, 74 insertions(+), 5 deletions(-) diff --git a/docs/agentmemory-codex-memory-replacement-spec.md b/docs/agentmemory-codex-memory-replacement-spec.md index 72cd811a0..6861cd4a7 100644 --- a/docs/agentmemory-codex-memory-replacement-spec.md +++ b/docs/agentmemory-codex-memory-replacement-spec.md @@ -63,6 +63,12 @@ The target end state is: - Codex-native memory generation and consolidation are disabled, - Codex retains or rebuilds only the product-level semantics that still add value, +- the fork uses the full \`agentmemory\` retrieval stack in steady state: + BM25 + vector + graph, +- embeddings are enabled by default in steady state; BM25-only mode is a + fallback, not the target architecture, +- lifecycle capture uses the widest useful hook surface rather than the minimum + viable subset, - the fork presents one coherent memory system to users, - the resulting system is a functional superset of both: - \`agentmemory\` capture and retrieval strengths, @@ -71,6 +77,30 @@ The target end state is: In other words, the desired architecture is a Venn-diagram merge with one authoritative engine, not permanent coexistence of two competing memory stacks. +## Maximum-performance policy + +The intended end state should maximize \`agentmemory\`, not merely adopt it. + +That means: + +- use hybrid retrieval as the primary retrieval path, +- enable embeddings by default in the intended production configuration, +- preserve graph retrieval and relation-aware retrieval as first-class + capabilities, +- use progressive disclosure and token budgets instead of large static memory + injections wherever possible, +- implement enough hook coverage that the observation stream is rich rather + than sparse, +- treat BM25-only mode as an acceptable degraded mode, not as the target. + +Provider policy: + +- support all current \`agentmemory\` embedding providers, +- keep Gemini embeddings available as a first-class provider, +- prefer the best available embedding backend for the environment rather than + hardcoding a low-capability default in the architecture, +- avoid designing the replacement around a no-embeddings baseline. + ## Scope This spec compares: @@ -479,7 +509,8 @@ Costs: - must replace or remove `UpdateMemories` and `DropMemories`, - must decide what to do about native memory citations, - must replace or drop `polluted`/thread memory-mode semantics, -- must extend Codex hooks enough to make capture quality acceptable. +- must extend Codex hooks enough to make capture quality fully competitive with + the `agentmemory` model rather than merely acceptable. Risk: @@ -553,6 +584,20 @@ The right claim is narrower: - and that architecture is likely better long-term than Codex native memory for large memory corpora. +### Performance-oriented token policy + +The intended architecture should optimize for query-time token efficiency, not +artifact compatibility. + +That means: + +- prefer top-K retrieval over broad handbook injection, +- keep startup context bounded and relevance-ranked, +- expand details only on demand, +- avoid recreating a large static `MEMORY.md`-style injection layer on top of + `agentmemory`, +- measure steady-state tokens/query as a first-class success metric. + ## Recommendation Target hard replacement as the end state. @@ -593,17 +638,33 @@ replacement rather than toward permanent coexistence. - Replace startup memory prompt generation with `agentmemory` retrieval. - Add equivalent user-facing operations for refresh and clear. - Decide whether these call into `agentmemory` REST/MCP or a local adapter. +- Route startup injection through the bounded `agentmemory` retrieval path + rather than recreating Codex-native memory artifact loading. +- Make token budget, retrieval mode, and expansion behavior explicit parts of + the adapter contract. ### Phase 3: hook expansion -- Extend Codex hook coverage enough to support useful `agentmemory` capture. -- Minimum likely useful additions: - - `SessionEnd` +- Extend Codex hook coverage to support the full useful `agentmemory` + observation model, not just a minimum subset. +- Target the full current `agentmemory` hook set: + - `SessionStart` + - `UserPromptSubmit` + - `PreToolUse` + - `PostToolUse` - `PostToolUseFailure` + - `PreCompact` - `SubagentStart` - `SubagentStop` - `Notification` - - `PreCompact` + - `TaskCompleted` + - `Stop` + - `SessionEnd` +- Broaden `PreToolUse` and `PostToolUse` beyond the current shell-centric + path so file tools, command tools, and other high-signal tool classes are + observed consistently. +- Do not treat hook expansion as optional polish; it is core to achieving the + high-performance end state. ### Phase 4: native memory deprecation @@ -619,6 +680,10 @@ replacement rather than toward permanent coexistence. - Verify that there is only one authoritative memory source in the runtime. - Remove any remaining code paths that can accidentally re-enable split-brain behavior. +- Verify that embeddings, graph retrieval, and progressive disclosure are + active in the intended steady-state configuration. +- Verify that the system is not silently falling back to a lower-capability + retrieval mode in normal operation. ### Phase 6: optional advanced alignment @@ -652,6 +717,10 @@ The replacement is successful only if all of these are true: intentionally removed with docs, - hook/event coverage is sufficient to produce materially useful observations, - token usage stays bounded as the corpus grows, +- the intended steady state uses embeddings and hybrid retrieval rather than a + degraded BM25-only baseline, +- Gemini or another high-quality embedding provider remains available as a + first-class configuration path, - the fork has a clear provenance story for memory-derived output, - there is no ambiguity about which memory system is active, - the resulting user-facing behavior is a practical superset of the two source From fb371104a008dfed7a850747bd04c03e9c412408 Mon Sep 17 00:00:00 2001 From: Eric Juta Date: Thu, 26 Mar 2026 13:26:18 +0000 Subject: [PATCH 05/45] docs: note agentmemory env alignment\n\nCo-authored-by: Codex --- ...entmemory-codex-memory-replacement-spec.md | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/docs/agentmemory-codex-memory-replacement-spec.md b/docs/agentmemory-codex-memory-replacement-spec.md index 6861cd4a7..7c09b2b69 100644 --- a/docs/agentmemory-codex-memory-replacement-spec.md +++ b/docs/agentmemory-codex-memory-replacement-spec.md @@ -164,6 +164,39 @@ The local `agentmemory` checkout is currently dirty. This matters only as a reminder not to treat the local repo state as release-tagged truth; the source shape is still adequate for architectural comparison. +### Current env alignment + +The live worker configuration is not sourced from +`~/Projects/agentmemory/.env`. In this checkout, `docker-compose.yml` points +the worker at: + +- `\${HOME}/.agentmemory/.env` + +Current externally loaded env alignment, verified in redacted form: + +- `GEMINI_API_KEY` is present, +- `GEMINI_MODEL` is present, +- `GEMINI_EMBEDDING_MODEL` is present, +- `GEMINI_EMBEDDING_DIMENSIONS` is present, +- `GRAPH_EXTRACTION_ENABLED` is present, +- `CONSOLIDATION_ENABLED` is present. + +Implications: + +- the current live environment already aligns with Gemini-first provider + selection, +- embedding auto-detection should resolve to Gemini unless explicitly + overridden, +- graph extraction and consolidation are already enabled in the current + external env, +- the current external env does not explicitly pin `EMBEDDING_PROVIDER`, + `TOKEN_BUDGET`, `BM25_WEIGHT`, `VECTOR_WEIGHT`, or + `FALLBACK_PROVIDERS`, so those currently rely on code defaults rather than + explicit ops policy. + +For a maximum-performance steady state, that last point should be treated as a +configuration gap, not as the desired final setup. + ## Codex native memory: what it is Codex native memory is a core-managed memory pipeline, not just a retrieval From 0d6d7053402572eebd45384302d91f87a145db3c Mon Sep 17 00:00:00 2001 From: Eric Juta Date: Thu, 26 Mar 2026 16:01:24 +0000 Subject: [PATCH 06/45] docs: add replacement execution plan\n\nCo-authored-by: Codex --- ...entmemory-codex-memory-replacement-spec.md | 238 ++++++++++++++++++ 1 file changed, 238 insertions(+) diff --git a/docs/agentmemory-codex-memory-replacement-spec.md b/docs/agentmemory-codex-memory-replacement-spec.md index 7c09b2b69..17fc2cf37 100644 --- a/docs/agentmemory-codex-memory-replacement-spec.md +++ b/docs/agentmemory-codex-memory-replacement-spec.md @@ -725,6 +725,244 @@ replacement rather than toward permanent coexistence. - Add richer protocol and app-server visibility if needed. - Reassess whether any remaining native memory logic should survive. +## Execution plan + +This section turns the replacement architecture into a low-rebase execution +plan. + +The key rule is: + +- keep invasive edits concentrated in a few upstream-hot orchestration files, +- keep most new logic in fork-owned modules, +- gate native behavior off before deleting it. + +### Allowed write boundaries + +The preferred fork seam is: + +- small edits in: + - `codex-rs/core/src/codex.rs` + - `codex-rs/core/src/hook_runtime.rs` + - `codex-rs/hooks/src/engine/config.rs` + - `codex-rs/hooks/src/engine/discovery.rs` + - hook event/schema files only when required for new public events +- most new implementation in new fork-owned modules, for example: + - `codex-rs/core/src/agentmemory/` + - `codex-rs/hooks/src/agentmemory/` or equivalent hook-translation module + +### Intentionally untouched until cutover + +Do not broadly rewrite these early: + +- `codex-rs/core/src/memories/*` +- `codex-rs/core/templates/memories/*` +- native memory artifact generation logic +- broad protocol/app-server surfaces unrelated to memory provenance + +Early phases should gate or bypass these paths, not delete or refactor them. + +### Branch order + +Use a short stack of focused branches / PRs. + +#### PR 1: backend selector and fork seam + +Goal: + +- introduce a clear memory backend selector, +- add the new `agentmemory` adapter module skeleton, +- make no user-visible behavior change yet. + +Write scope: + +- config wiring, +- new adapter modules, +- minimal callsite plumbing only where needed. + +Must not do: + +- no native memory deletion, +- no protocol changes, +- no hook expansion yet. + +Merge gate: + +- no behavior regression with native memory still active by default, +- docs updated to describe the seam. + +#### PR 2: startup injection replacement + +Goal: + +- route startup memory injection through the `agentmemory` adapter, +- make bounded retrieval the new startup path, +- stop depending on native memory artifact loading for startup context. + +Write scope: + +- `codex-rs/core/src/codex.rs` +- adapter module +- minimal config/docs updates + +Must not do: + +- do not delete native memories yet, +- do not add broad protocol changes, +- do not expand hook coverage in the same PR. + +Merge gate: + +- startup context is sourced from `agentmemory`, +- token budget and retrieval mode are explicit and tested, +- no static `MEMORY.md`-style reinjection layer is recreated on top. + +#### PR 3: public hook event expansion + +Goal: + +- expand Codex hooks to cover the full useful `agentmemory` hook set: + - `SessionStart` + - `UserPromptSubmit` + - `PreToolUse` + - `PostToolUse` + - `PostToolUseFailure` + - `PreCompact` + - `SubagentStart` + - `SubagentStop` + - `Notification` + - `TaskCompleted` + - `Stop` + - `SessionEnd` + +Write scope: + +- hook config/schema/discovery/runtime files, +- TUI/app-server visibility only where hook runs need surfacing. + +Must not do: + +- do not mix in native memory deletion, +- do not mix in citation replacement. + +Merge gate: + +- each event has runtime dispatch, +- each event is documented, +- hook run visibility remains coherent. + +#### PR 4: tool coverage broadening + +Goal: + +- broaden `PreToolUse` and `PostToolUse` beyond the shell-centric path, +- ensure file tools, command tools, and other high-signal tool classes are + observed consistently for `agentmemory`. + +Write scope: + +- `codex-rs/core/src/hook_runtime.rs` +- tool handler payload plumbing +- hook translation layer + +Must not do: + +- do not mix in memory command replacement, +- do not delete native memory paths yet. + +Merge gate: + +- high-signal tool classes emit useful observation payloads, +- no regression in existing shell-hook flows. + +#### PR 5: memory ops and provenance replacement + +Goal: + +- replace or redefine `UpdateMemories` and `DropMemories`, +- decide and implement provenance behavior, +- define the replacement for native `polluted` semantics. + +Write scope: + +- memory command handlers, +- provenance/citation integration, +- minimal protocol additions if absolutely required. + +Must not do: + +- do not combine this with broad deletion of native memory code. + +Merge gate: + +- user-facing memory refresh/clear actions still exist or are intentionally + documented as removed, +- provenance behavior is explicit, +- no ambiguity remains about memory invalidation rules. + +#### PR 6: hard cutover + +Goal: + +- disable native memory generation and consolidation in normal runtime paths, +- make `agentmemory` the only authoritative memory backend, +- quarantine or deprecate native memory artifacts. + +Write scope: + +- backend selection defaults, +- final cutover gating, +- cleanup of callsites that can still route to native memory. + +Must not do: + +- do not do broad code deletion unless the fork is already stable after cutover, +- do not remove debug/rollback switches until at least one successful rebase + cycle after cutover. + +Merge gate: + +- one memory authority in runtime, +- no split-brain injection, +- no accidental native fallback in standard flows. + +#### PR 7: post-cutover cleanup + +Goal: + +- remove dead native-memory paths only after the hard cutover has stabilized. + +Write scope: + +- native memory code and docs that are no longer reachable, +- migration notes if existing users need them. + +Merge gate: + +- cleanup produces less rebase churn than it creates, +- rollback path is no longer needed. + +### Rebase policy + +- Rebase frequently; do not let this stack drift for long. +- Rebase before opening each PR and after any upstream changes touching: + - `codex-rs/core/src/codex.rs` + - `codex-rs/core/src/hook_runtime.rs` + - hook engine config/discovery/schema/runtime files +- Prefer new modules over editing existing modules repeatedly. +- If a behavior can live in the adapter, keep it out of upstream-hot files. +- Do not delete upstream code early; disabling is cheaper to rebase than + removal. + +### Success metrics by PR + +- PR 1: seam exists with no behavior regression. +- PR 2: startup injection is `agentmemory`-backed and token-bounded. +- PR 3: hook surface matches the intended `agentmemory` event model. +- PR 4: observation capture is rich across the important tool classes. +- PR 5: memory ops and provenance no longer depend on native memory internals. +- PR 6: runtime has one authoritative memory backend. +- PR 7: dead code removal does not increase future rebase cost materially. + ## Do not do - Do not run Codex native memory injection and `agentmemory` injection as From 3792d2fb8bb89309020c9b0d9401b3106428d8c4 Mon Sep 17 00:00:00 2001 From: Eric Juta Date: Thu, 26 Mar 2026 16:10:33 +0000 Subject: [PATCH 07/45] docs: add handoff prompts to replacement plan\n\nCo-authored-by: Codex --- ...entmemory-codex-memory-replacement-spec.md | 181 ++++++++++++++++++ 1 file changed, 181 insertions(+) diff --git a/docs/agentmemory-codex-memory-replacement-spec.md b/docs/agentmemory-codex-memory-replacement-spec.md index 17fc2cf37..451fa4a4c 100644 --- a/docs/agentmemory-codex-memory-replacement-spec.md +++ b/docs/agentmemory-codex-memory-replacement-spec.md @@ -963,6 +963,187 @@ Merge gate: - PR 6: runtime has one authoritative memory backend. - PR 7: dead code removal does not increase future rebase cost materially. +### Handoff prompts by PR + +These are intended as copy-paste prompts for future sessions, child agents, or +parallel worker swarms. Each prompt is deliberately scoped to one PR-sized +slice. + +#### PR 1 handoff prompt + +```text +Implement PR 1 from docs/agentmemory-codex-memory-replacement-spec.md. + +Goal: +- introduce a clear memory backend selector +- add the new agentmemory adapter seam +- make no user-visible behavior change yet + +Constraints: +- keep invasive edits concentrated +- do not delete or broadly rewrite codex-rs/core/src/memories/* +- do not change protocol shapes +- do not expand hooks yet + +Write scope: +- config wiring +- new fork-owned adapter modules +- minimal callsite plumbing only where needed + +Acceptance: +- native memory remains default and behaviorally unchanged +- the seam exists and is documented +- code is structured so later PRs can route through the adapter without large rewrites +``` + +#### PR 2 handoff prompt + +```text +Implement PR 2 from docs/agentmemory-codex-memory-replacement-spec.md. + +Goal: +- replace startup memory prompt generation with agentmemory-backed retrieval +- make startup context bounded, relevance-ranked, and token-budgeted + +Constraints: +- do not recreate static MEMORY.md-style loading on top of agentmemory +- do not expand hooks in this PR +- do not delete native memory code yet + +Write scope: +- codex-rs/core/src/codex.rs +- agentmemory adapter module +- small config/docs updates if required + +Acceptance: +- startup injection is sourced through the adapter +- retrieval mode and token budget are explicit +- native memory still exists only as a gated fallback path, not the main path +``` + +#### PR 3 handoff prompt + +```text +Implement PR 3 from docs/agentmemory-codex-memory-replacement-spec.md. + +Goal: +- expand Codex public hooks to support the full useful agentmemory event model + +Target events: +- SessionStart +- UserPromptSubmit +- PreToolUse +- PostToolUse +- PostToolUseFailure +- PreCompact +- SubagentStart +- SubagentStop +- Notification +- TaskCompleted +- Stop +- SessionEnd + +Constraints: +- keep handler semantics coherent +- do not mix in native memory deletion +- do not mix in provenance/citation replacement + +Acceptance: +- each target event is represented in config/discovery/runtime +- documentation and hook-run visibility are updated +- new events do not regress existing hook behavior +``` + +#### PR 4 handoff prompt + +```text +Implement PR 4 from docs/agentmemory-codex-memory-replacement-spec.md. + +Goal: +- broaden PreToolUse and PostToolUse beyond the shell-centric path +- ensure high-signal tool classes produce useful agentmemory observations + +Constraints: +- prioritize file tools, command tools, and other high-signal tool classes +- do not mix in memory command replacement +- do not cut over the backend here + +Acceptance: +- important tool classes emit observation payloads consistently +- shell-hook behavior still works +- capture quality is materially closer to the Claude-side agentmemory model +``` + +#### PR 5 handoff prompt + +```text +Implement PR 5 from docs/agentmemory-codex-memory-replacement-spec.md. + +Goal: +- replace or redefine UpdateMemories and DropMemories +- decide and implement provenance behavior +- define the replacement for native polluted semantics + +Constraints: +- keep protocol churn minimal unless required +- make user-facing behavior explicit +- do not delete native memory paths in this PR + +Acceptance: +- memory refresh/clear actions still exist or are intentionally removed with docs +- provenance behavior is explicit +- invalidation rules are no longer ambiguous +``` + +#### PR 6 handoff prompt + +```text +Implement PR 6 from docs/agentmemory-codex-memory-replacement-spec.md. + +Goal: +- make agentmemory the only authoritative runtime memory backend +- disable native memory generation and consolidation in normal runtime paths + +Constraints: +- do not do broad dead-code deletion yet +- keep rollback/debug switches until cutover is validated + +Acceptance: +- one memory authority remains in runtime +- no split-brain injection is possible in standard flows +- native paths are gated off rather than accidentally still active +``` + +#### PR 7 handoff prompt + +```text +Implement PR 7 from docs/agentmemory-codex-memory-replacement-spec.md. + +Goal: +- perform post-cutover cleanup only after the hard replacement is stable + +Constraints: +- prefer cleanup that reduces future rebase cost +- do not remove rollback/debug tooling prematurely + +Acceptance: +- dead native-memory paths are removed only when safe +- cleanup does not create more rebase drag than it removes +``` + +#### Cross-PR reviewer prompt + +```text +Review the current PR against docs/agentmemory-codex-memory-replacement-spec.md. + +Focus: +- does this PR stay within its assigned write boundary +- does it reduce or increase future rebase drag +- does it preserve the hard-replacement target +- does it accidentally introduce split-brain behavior +- does it move the system toward maximum-performance agentmemory usage rather than a degraded fallback +``` + ## Do not do - Do not run Codex native memory injection and `agentmemory` injection as From 1a8eebeef246223c35f6e6f564dadbe67f1cd173 Mon Sep 17 00:00:00 2001 From: Eric Juta Date: Thu, 26 Mar 2026 16:36:12 +0000 Subject: [PATCH 08/45] feat: introduce memory backend selector and agentmemory adapter seam This implements PR 1 of the agentmemory replacement spec. It introduces a `backend` config option for memories (defaulting to `native`) and sets up a new `agentmemory` module adapter seam without changing any user-visible behavior. --- codex-rs/core/src/agentmemory/mod.rs | 16 ++++++++++++++++ codex-rs/core/src/config/types.rs | 13 +++++++++++++ codex-rs/core/src/lib.rs | 1 + 3 files changed, 30 insertions(+) create mode 100644 codex-rs/core/src/agentmemory/mod.rs diff --git a/codex-rs/core/src/agentmemory/mod.rs b/codex-rs/core/src/agentmemory/mod.rs new file mode 100644 index 000000000..1c7cde5df --- /dev/null +++ b/codex-rs/core/src/agentmemory/mod.rs @@ -0,0 +1,16 @@ +//! Agentmemory integration adapter. +//! +//! This module provides the seam for integrating the `agentmemory` service +//! as a replacement for Codex's native memory engine. + +/// A placeholder adapter struct for agentmemory integration. +#[derive(Debug, Default, Clone)] +pub struct AgentmemoryAdapter { + // Configuration and state will be added here in subsequent PRs. +} + +impl AgentmemoryAdapter { + pub fn new() -> Self { + Self::default() + } +} \ No newline at end of file diff --git a/codex-rs/core/src/config/types.rs b/codex-rs/core/src/config/types.rs index a69fec343..b88318e47 100644 --- a/codex-rs/core/src/config/types.rs +++ b/codex-rs/core/src/config/types.rs @@ -412,10 +412,20 @@ pub struct ToolSuggestConfig { pub discoverables: Vec, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum MemoryBackend { + #[default] + Native, + Agentmemory, +} + /// Memories settings loaded from config.toml. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)] #[schemars(deny_unknown_fields)] pub struct MemoriesToml { + /// The backend to use for memories. + pub backend: Option, /// When `true`, web searches and MCP tool calls mark the thread `memory_mode` as `"polluted"`. pub no_memories_if_mcp_or_web_search: Option, /// When `false`, newly created threads are stored with `memory_mode = "disabled"` in the state DB. @@ -441,6 +451,7 @@ pub struct MemoriesToml { /// Effective memories settings after defaults are applied. #[derive(Debug, Clone, PartialEq, Eq)] pub struct MemoriesConfig { + pub backend: MemoryBackend, pub no_memories_if_mcp_or_web_search: bool, pub generate_memories: bool, pub use_memories: bool, @@ -456,6 +467,7 @@ pub struct MemoriesConfig { impl Default for MemoriesConfig { fn default() -> Self { Self { + backend: MemoryBackend::default(), no_memories_if_mcp_or_web_search: false, generate_memories: true, use_memories: true, @@ -474,6 +486,7 @@ impl From for MemoriesConfig { fn from(toml: MemoriesToml) -> Self { let defaults = Self::default(); Self { + backend: toml.backend.unwrap_or(defaults.backend), no_memories_if_mcp_or_web_search: toml .no_memories_if_mcp_or_web_search .unwrap_or(defaults.no_memories_if_mcp_or_web_search), diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 1ed3250f3..4789cd4c6 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -59,6 +59,7 @@ pub use mcp_connection_manager::SandboxState; pub use text_encoding::bytes_to_string_smart; mod mcp_tool_call; mod memories; +pub mod agentmemory; pub mod mention_syntax; pub mod message_history; mod model_provider_info; From 620fe2a782814e0035e28fa88a362db5d9840d46 Mon Sep 17 00:00:00 2001 From: Eric Juta Date: Thu, 26 Mar 2026 16:39:27 +0000 Subject: [PATCH 09/45] feat: replace startup memory prompt generation with agentmemory-backed retrieval This implements PR 2 of the agentmemory replacement spec. It routes startup memory injection through the new `agentmemory` adapter when the backend config is set to `agentmemory`. The adapter currently provides a bounded and relevance-ranked context via a token budget. Native memory still exists as the default gated fallback path. --- codex-rs/core/src/agentmemory/mod.rs | 21 +++++++++++++++++++++ codex-rs/core/src/codex.rs | 18 +++++++++++++++--- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/codex-rs/core/src/agentmemory/mod.rs b/codex-rs/core/src/agentmemory/mod.rs index 1c7cde5df..8fe803abb 100644 --- a/codex-rs/core/src/agentmemory/mod.rs +++ b/codex-rs/core/src/agentmemory/mod.rs @@ -3,6 +3,8 @@ //! This module provides the seam for integrating the `agentmemory` service //! as a replacement for Codex's native memory engine. +use std::path::Path; + /// A placeholder adapter struct for agentmemory integration. #[derive(Debug, Default, Clone)] pub struct AgentmemoryAdapter { @@ -13,4 +15,23 @@ impl AgentmemoryAdapter { pub fn new() -> Self { Self::default() } + + /// Builds the developer instructions for startup memory injection + /// using the `agentmemory` retrieval stack. + /// + /// This retrieves context bounded by a token budget and explicitly + /// uses hybrid search semantics rather than loading large static artifacts. + pub async fn build_startup_developer_instructions( + &self, + _codex_home: &Path, + _token_budget: usize, + ) -> Option { + // TODO: Call agentmemory REST/MCP endpoints to fetch top-K results + // For now, return a placeholder instructions block. + Some( + "Use the `AgentMemory` tools to search and retrieve relevant memory.\n\ + Your context is bounded; use targeted queries to expand details as needed." + .to_string(), + ) + } } \ No newline at end of file diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index f29d37fe2..77fb4636a 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -3510,10 +3510,22 @@ impl Session { // Add developer instructions for memories. if turn_context.features.enabled(Feature::MemoryTool) && turn_context.config.memories.use_memories - && let Some(memory_prompt) = - build_memory_tool_developer_instructions(&turn_context.config.codex_home).await { - developer_sections.push(memory_prompt); + let memory_prompt_opt = match turn_context.config.memories.backend { + crate::config::types::MemoryBackend::Agentmemory => { + let adapter = crate::agentmemory::AgentmemoryAdapter::new(); + // Provide a default explicit token budget for the startup query context + adapter + .build_startup_developer_instructions(&turn_context.config.codex_home, 2000) + .await + } + crate::config::types::MemoryBackend::Native => { + build_memory_tool_developer_instructions(&turn_context.config.codex_home).await + } + }; + if let Some(memory_prompt) = memory_prompt_opt { + developer_sections.push(memory_prompt); + } } // Add developer instructions from collaboration_mode if they exist and are non-empty if let Some(collab_instructions) = From 00b2411de35a12d2812b159206e52330454b969f Mon Sep 17 00:00:00 2001 From: Eric Juta Date: Thu, 26 Mar 2026 17:01:49 +0000 Subject: [PATCH 10/45] feat: expand Codex public hooks to support the full agentmemory event model This implements PR 3 of the agentmemory replacement spec. It expands the `codex_hooks` surface to support the remaining Claude-oriented lifecycle events: PostToolUseFailure, PreCompact, SessionStart, SubagentStart, SubagentStop, Notification, TaskCompleted, and SessionEnd. These are fully parsed from `hooks.json` and discovered, with the required enums and JSON schema wiring updated. --- .../app-server-protocol/src/protocol/v2.rs | 2 +- .../post-tool-use.command.output.schema.json | 7 ++++ .../pre-tool-use.command.output.schema.json | 7 ++++ .../session-start.command.output.schema.json | 7 ++++ ...r-prompt-submit.command.output.schema.json | 7 ++++ codex-rs/hooks/src/engine/config.rs | 22 +++++++++--- codex-rs/hooks/src/engine/discovery.rs | 35 +++++++++++++++++++ codex-rs/hooks/src/engine/dispatcher.rs | 18 ++++++++-- codex-rs/hooks/src/engine/mod.rs | 7 ++++ codex-rs/hooks/src/events/common.rs | 13 +++++-- codex-rs/hooks/src/schema.rs | 14 ++++++++ codex-rs/protocol/src/protocol.rs | 7 ++++ codex-rs/tui/src/chatwidget.rs | 7 ++++ codex-rs/tui_app_server/src/chatwidget.rs | 7 ++++ 14 files changed, 150 insertions(+), 10 deletions(-) diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 147c91db2..f32fe0463 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -377,7 +377,7 @@ v2_enum_from_core!( v2_enum_from_core!( pub enum HookEventName from CoreHookEventName { - PreToolUse, PostToolUse, SessionStart, UserPromptSubmit, Stop + PreToolUse, PostToolUse, PostToolUseFailure, PreCompact, SessionStart, SubagentStart, SubagentStop, Notification, TaskCompleted, UserPromptSubmit, Stop, SessionEnd } ); diff --git a/codex-rs/hooks/schema/generated/post-tool-use.command.output.schema.json b/codex-rs/hooks/schema/generated/post-tool-use.command.output.schema.json index dc0425a75..b0c6f7f55 100644 --- a/codex-rs/hooks/schema/generated/post-tool-use.command.output.schema.json +++ b/codex-rs/hooks/schema/generated/post-tool-use.command.output.schema.json @@ -12,7 +12,14 @@ "enum": [ "PreToolUse", "PostToolUse", + "PostToolUseFailure", + "PreCompact", "SessionStart", + "SessionEnd", + "SubagentStart", + "SubagentStop", + "Notification", + "TaskCompleted", "UserPromptSubmit", "Stop" ], diff --git a/codex-rs/hooks/schema/generated/pre-tool-use.command.output.schema.json b/codex-rs/hooks/schema/generated/pre-tool-use.command.output.schema.json index a7ff1d5f7..6aab0eb0f 100644 --- a/codex-rs/hooks/schema/generated/pre-tool-use.command.output.schema.json +++ b/codex-rs/hooks/schema/generated/pre-tool-use.command.output.schema.json @@ -6,7 +6,14 @@ "enum": [ "PreToolUse", "PostToolUse", + "PostToolUseFailure", + "PreCompact", "SessionStart", + "SessionEnd", + "SubagentStart", + "SubagentStop", + "Notification", + "TaskCompleted", "UserPromptSubmit", "Stop" ], diff --git a/codex-rs/hooks/schema/generated/session-start.command.output.schema.json b/codex-rs/hooks/schema/generated/session-start.command.output.schema.json index d79ab2a9a..b44c19e70 100644 --- a/codex-rs/hooks/schema/generated/session-start.command.output.schema.json +++ b/codex-rs/hooks/schema/generated/session-start.command.output.schema.json @@ -6,7 +6,14 @@ "enum": [ "PreToolUse", "PostToolUse", + "PostToolUseFailure", + "PreCompact", "SessionStart", + "SessionEnd", + "SubagentStart", + "SubagentStop", + "Notification", + "TaskCompleted", "UserPromptSubmit", "Stop" ], diff --git a/codex-rs/hooks/schema/generated/user-prompt-submit.command.output.schema.json b/codex-rs/hooks/schema/generated/user-prompt-submit.command.output.schema.json index 4f63bec89..a95f272be 100644 --- a/codex-rs/hooks/schema/generated/user-prompt-submit.command.output.schema.json +++ b/codex-rs/hooks/schema/generated/user-prompt-submit.command.output.schema.json @@ -12,7 +12,14 @@ "enum": [ "PreToolUse", "PostToolUse", + "PostToolUseFailure", + "PreCompact", "SessionStart", + "SessionEnd", + "SubagentStart", + "SubagentStop", + "Notification", + "TaskCompleted", "UserPromptSubmit", "Stop" ], diff --git a/codex-rs/hooks/src/engine/config.rs b/codex-rs/hooks/src/engine/config.rs index 839fee825..5307226dc 100644 --- a/codex-rs/hooks/src/engine/config.rs +++ b/codex-rs/hooks/src/engine/config.rs @@ -8,16 +8,30 @@ pub(crate) struct HooksFile { #[derive(Debug, Default, Deserialize)] pub(crate) struct HookEvents { - #[serde(rename = "PreToolUse", default)] - pub pre_tool_use: Vec, - #[serde(rename = "PostToolUse", default)] - pub post_tool_use: Vec, #[serde(rename = "SessionStart", default)] pub session_start: Vec, #[serde(rename = "UserPromptSubmit", default)] pub user_prompt_submit: Vec, + #[serde(rename = "PreToolUse", default)] + pub pre_tool_use: Vec, + #[serde(rename = "PostToolUse", default)] + pub post_tool_use: Vec, + #[serde(rename = "PostToolUseFailure", default)] + pub post_tool_use_failure: Vec, + #[serde(rename = "PreCompact", default)] + pub pre_compact: Vec, + #[serde(rename = "SubagentStart", default)] + pub subagent_start: Vec, + #[serde(rename = "SubagentStop", default)] + pub subagent_stop: Vec, + #[serde(rename = "Notification", default)] + pub notification: Vec, + #[serde(rename = "TaskCompleted", default)] + pub task_completed: Vec, #[serde(rename = "Stop", default)] pub stop: Vec, + #[serde(rename = "SessionEnd", default)] + pub session_end: Vec, } #[derive(Debug, Default, Deserialize)] diff --git a/codex-rs/hooks/src/engine/discovery.rs b/codex-rs/hooks/src/engine/discovery.rs index f39eb7743..27985dbe7 100644 --- a/codex-rs/hooks/src/engine/discovery.rs +++ b/codex-rs/hooks/src/engine/discovery.rs @@ -74,9 +74,16 @@ pub(crate) fn discover_handlers(config_layer_stack: Option<&ConfigLayerStack>) - let super::config::HookEvents { pre_tool_use, post_tool_use, + post_tool_use_failure, + pre_compact, session_start, + subagent_start, + subagent_stop, + notification, + task_completed, user_prompt_submit, stop, + session_end, } = parsed.hooks; for (event_name, groups) in [ @@ -88,15 +95,43 @@ pub(crate) fn discover_handlers(config_layer_stack: Option<&ConfigLayerStack>) - codex_protocol::protocol::HookEventName::PostToolUse, post_tool_use, ), + ( + codex_protocol::protocol::HookEventName::PostToolUseFailure, + post_tool_use_failure, + ), + ( + codex_protocol::protocol::HookEventName::PreCompact, + pre_compact, + ), ( codex_protocol::protocol::HookEventName::SessionStart, session_start, ), + ( + codex_protocol::protocol::HookEventName::SubagentStart, + subagent_start, + ), + ( + codex_protocol::protocol::HookEventName::SubagentStop, + subagent_stop, + ), + ( + codex_protocol::protocol::HookEventName::Notification, + notification, + ), + ( + codex_protocol::protocol::HookEventName::TaskCompleted, + task_completed, + ), ( codex_protocol::protocol::HookEventName::UserPromptSubmit, user_prompt_submit, ), (codex_protocol::protocol::HookEventName::Stop, stop), + ( + codex_protocol::protocol::HookEventName::SessionEnd, + session_end, + ), ] { append_matcher_groups( &mut handlers, diff --git a/codex-rs/hooks/src/engine/dispatcher.rs b/codex-rs/hooks/src/engine/dispatcher.rs index 133fc4898..ae064de6f 100644 --- a/codex-rs/hooks/src/engine/dispatcher.rs +++ b/codex-rs/hooks/src/engine/dispatcher.rs @@ -33,7 +33,14 @@ pub(crate) fn select_handlers( .filter(|handler| match event_name { HookEventName::PreToolUse | HookEventName::PostToolUse - | HookEventName::SessionStart => { + | HookEventName::PostToolUseFailure + | HookEventName::PreCompact + | HookEventName::SessionStart + | HookEventName::SessionEnd + | HookEventName::SubagentStart + | HookEventName::SubagentStop + | HookEventName::Notification + | HookEventName::TaskCompleted => { matches_matcher(handler.matcher.as_deref(), matcher_input) } HookEventName::UserPromptSubmit | HookEventName::Stop => true, @@ -107,9 +114,16 @@ pub(crate) fn completed_summary( fn scope_for_event(event_name: HookEventName) -> HookScope { match event_name { - HookEventName::SessionStart => HookScope::Thread, + HookEventName::SessionStart + | HookEventName::SessionEnd + | HookEventName::PreCompact + | HookEventName::TaskCompleted => HookScope::Thread, HookEventName::PreToolUse | HookEventName::PostToolUse + | HookEventName::PostToolUseFailure + | HookEventName::SubagentStart + | HookEventName::SubagentStop + | HookEventName::Notification | HookEventName::UserPromptSubmit | HookEventName::Stop => HookScope::Turn, } diff --git a/codex-rs/hooks/src/engine/mod.rs b/codex-rs/hooks/src/engine/mod.rs index f91fee24c..ded1cb05c 100644 --- a/codex-rs/hooks/src/engine/mod.rs +++ b/codex-rs/hooks/src/engine/mod.rs @@ -52,9 +52,16 @@ impl ConfiguredHandler { match self.event_name { codex_protocol::protocol::HookEventName::PreToolUse => "pre-tool-use", codex_protocol::protocol::HookEventName::PostToolUse => "post-tool-use", + codex_protocol::protocol::HookEventName::PostToolUseFailure => "post-tool-use-failure", + codex_protocol::protocol::HookEventName::PreCompact => "pre-compact", codex_protocol::protocol::HookEventName::SessionStart => "session-start", + codex_protocol::protocol::HookEventName::SubagentStart => "subagent-start", + codex_protocol::protocol::HookEventName::SubagentStop => "subagent-stop", + codex_protocol::protocol::HookEventName::Notification => "notification", + codex_protocol::protocol::HookEventName::TaskCompleted => "task-completed", codex_protocol::protocol::HookEventName::UserPromptSubmit => "user-prompt-submit", codex_protocol::protocol::HookEventName::Stop => "stop", + codex_protocol::protocol::HookEventName::SessionEnd => "session-end", } } } diff --git a/codex-rs/hooks/src/events/common.rs b/codex-rs/hooks/src/events/common.rs index 48fbed242..7213dbddb 100644 --- a/codex-rs/hooks/src/events/common.rs +++ b/codex-rs/hooks/src/events/common.rs @@ -74,9 +74,16 @@ pub(crate) fn matcher_pattern_for_event( matcher: Option<&str>, ) -> Option<&str> { match event_name { - HookEventName::PreToolUse | HookEventName::PostToolUse | HookEventName::SessionStart => { - matcher - } + HookEventName::PreToolUse + | HookEventName::PostToolUse + | HookEventName::PostToolUseFailure + | HookEventName::SessionStart + | HookEventName::SessionEnd + | HookEventName::PreCompact + | HookEventName::SubagentStart + | HookEventName::SubagentStop + | HookEventName::Notification + | HookEventName::TaskCompleted => matcher, HookEventName::UserPromptSubmit | HookEventName::Stop => None, } } diff --git a/codex-rs/hooks/src/schema.rs b/codex-rs/hooks/src/schema.rs index b1a5017cd..06f05a113 100644 --- a/codex-rs/hooks/src/schema.rs +++ b/codex-rs/hooks/src/schema.rs @@ -71,8 +71,22 @@ pub(crate) enum HookEventNameWire { PreToolUse, #[serde(rename = "PostToolUse")] PostToolUse, + #[serde(rename = "PostToolUseFailure")] + PostToolUseFailure, + #[serde(rename = "PreCompact")] + PreCompact, #[serde(rename = "SessionStart")] SessionStart, + #[serde(rename = "SessionEnd")] + SessionEnd, + #[serde(rename = "SubagentStart")] + SubagentStart, + #[serde(rename = "SubagentStop")] + SubagentStop, + #[serde(rename = "Notification")] + Notification, + #[serde(rename = "TaskCompleted")] + TaskCompleted, #[serde(rename = "UserPromptSubmit")] UserPromptSubmit, #[serde(rename = "Stop")] diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 2e67fa5d7..c28cb4dfe 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1409,9 +1409,16 @@ pub enum EventMsg { pub enum HookEventName { PreToolUse, PostToolUse, + PostToolUseFailure, + PreCompact, SessionStart, + SubagentStart, + SubagentStop, + Notification, + TaskCompleted, UserPromptSubmit, Stop, + SessionEnd, } #[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 68497f4e0..59ed5bae6 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -9641,7 +9641,14 @@ fn hook_event_label(event_name: codex_protocol::protocol::HookEventName) -> &'st match event_name { codex_protocol::protocol::HookEventName::PreToolUse => "PreToolUse", codex_protocol::protocol::HookEventName::PostToolUse => "PostToolUse", + codex_protocol::protocol::HookEventName::PostToolUseFailure => "PostToolUseFailure", + codex_protocol::protocol::HookEventName::PreCompact => "PreCompact", codex_protocol::protocol::HookEventName::SessionStart => "SessionStart", + codex_protocol::protocol::HookEventName::SessionEnd => "SessionEnd", + codex_protocol::protocol::HookEventName::SubagentStart => "SubagentStart", + codex_protocol::protocol::HookEventName::SubagentStop => "SubagentStop", + codex_protocol::protocol::HookEventName::Notification => "Notification", + codex_protocol::protocol::HookEventName::TaskCompleted => "TaskCompleted", codex_protocol::protocol::HookEventName::UserPromptSubmit => "UserPromptSubmit", codex_protocol::protocol::HookEventName::Stop => "Stop", } diff --git a/codex-rs/tui_app_server/src/chatwidget.rs b/codex-rs/tui_app_server/src/chatwidget.rs index deb378ea2..438f47080 100644 --- a/codex-rs/tui_app_server/src/chatwidget.rs +++ b/codex-rs/tui_app_server/src/chatwidget.rs @@ -10917,7 +10917,14 @@ fn hook_event_label(event_name: codex_protocol::protocol::HookEventName) -> &'st match event_name { codex_protocol::protocol::HookEventName::PreToolUse => "PreToolUse", codex_protocol::protocol::HookEventName::PostToolUse => "PostToolUse", + codex_protocol::protocol::HookEventName::PostToolUseFailure => "PostToolUseFailure", + codex_protocol::protocol::HookEventName::PreCompact => "PreCompact", codex_protocol::protocol::HookEventName::SessionStart => "SessionStart", + codex_protocol::protocol::HookEventName::SessionEnd => "SessionEnd", + codex_protocol::protocol::HookEventName::SubagentStart => "SubagentStart", + codex_protocol::protocol::HookEventName::SubagentStop => "SubagentStop", + codex_protocol::protocol::HookEventName::Notification => "Notification", + codex_protocol::protocol::HookEventName::TaskCompleted => "TaskCompleted", codex_protocol::protocol::HookEventName::UserPromptSubmit => "UserPromptSubmit", codex_protocol::protocol::HookEventName::Stop => "Stop", } From 3eab67919e901793c5338aef73304a3efdcee9f6 Mon Sep 17 00:00:00 2001 From: Eric Juta Date: Thu, 26 Mar 2026 17:53:29 +0000 Subject: [PATCH 11/45] feat: emit tool usage events from high-signal tools for agentmemory This implements PR 4 of the agentmemory replacement spec. It updates the ToolHandler trait to automatically inject tool_name and payload into PreToolUse and PostToolUse observations. This expands capture beyond the shell handler to all other important tools including exec_command, patch application, and MCP tools (like ReadFile). The legacy shell command hook continues to work unchanged. --- codex-rs/core/config.schema.json | 15 ++++++ codex-rs/core/src/config/config_tests.rs | 2 + codex-rs/core/src/hook_runtime.rs | 6 ++- codex-rs/core/src/tools/context.rs | 20 ++++++- codex-rs/core/src/tools/handlers/shell.rs | 27 ++++++---- .../core/src/tools/handlers/shell_tests.rs | 20 +++++-- .../core/src/tools/handlers/unified_exec.rs | 13 +++-- .../src/tools/handlers/unified_exec_tests.rs | 53 +++++++++++++++---- codex-rs/core/src/tools/registry.rs | 35 +++++++----- 9 files changed, 146 insertions(+), 45 deletions(-) diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 300716dc1..858607da0 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -761,6 +761,14 @@ "additionalProperties": false, "description": "Memories settings loaded from config.toml.", "properties": { + "backend": { + "allOf": [ + { + "$ref": "#/definitions/MemoryBackend" + } + ], + "description": "The backend to use for memories." + }, "consolidation_model": { "description": "Model used for memory consolidation.", "type": "string" @@ -811,6 +819,13 @@ }, "type": "object" }, + "MemoryBackend": { + "enum": [ + "native", + "agentmemory" + ], + "type": "string" + }, "ModelAvailabilityNuxConfig": { "additionalProperties": { "format": "uint32", diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 7d2f6c573..bb7c175ff 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -149,6 +149,7 @@ consolidation_model = "gpt-5" toml::from_str::(memories).expect("TOML deserialization should succeed"); assert_eq!( Some(MemoriesToml { + backend: None, no_memories_if_mcp_or_web_search: Some(true), generate_memories: Some(false), use_memories: Some(false), @@ -172,6 +173,7 @@ consolidation_model = "gpt-5" assert_eq!( config.memories, MemoriesConfig { + backend: crate::config::types::MemoryBackend::default(), no_memories_if_mcp_or_web_search: true, generate_memories: false, use_memories: false, diff --git a/codex-rs/core/src/hook_runtime.rs b/codex-rs/core/src/hook_runtime.rs index ece93b1da..63fcec73d 100644 --- a/codex-rs/core/src/hook_runtime.rs +++ b/codex-rs/core/src/hook_runtime.rs @@ -117,6 +117,7 @@ pub(crate) async fn run_pending_session_start_hooks( pub(crate) async fn run_pre_tool_use_hooks( sess: &Arc, turn_context: &Arc, + tool_name: String, tool_use_id: String, command: String, ) -> Option { @@ -127,7 +128,7 @@ pub(crate) async fn run_pre_tool_use_hooks( transcript_path: sess.hook_transcript_path().await, model: turn_context.model_info.slug.clone(), permission_mode: hook_permission_mode(turn_context), - tool_name: "Bash".to_string(), + tool_name, tool_use_id, command, }; @@ -147,6 +148,7 @@ pub(crate) async fn run_pre_tool_use_hooks( pub(crate) async fn run_post_tool_use_hooks( sess: &Arc, turn_context: &Arc, + tool_name: String, tool_use_id: String, command: String, tool_response: Value, @@ -158,7 +160,7 @@ pub(crate) async fn run_post_tool_use_hooks( transcript_path: sess.hook_transcript_path().await, model: turn_context.model_info.slug.clone(), permission_mode: hook_permission_mode(turn_context), - tool_name: "Bash".to_string(), + tool_name, tool_use_id, command, tool_response, diff --git a/codex-rs/core/src/tools/context.rs b/codex-rs/core/src/tools/context.rs index 58d040cbe..ca3cec533 100644 --- a/codex-rs/core/src/tools/context.rs +++ b/codex-rs/core/src/tools/context.rs @@ -111,6 +111,10 @@ impl ToolOutput for CallToolResult { } } + fn post_tool_use_response(&self, _call_id: &str, _payload: &ToolPayload) -> Option { + serde_json::to_value(self).ok() + } + fn code_mode_result(&self, _payload: &ToolPayload) -> JsonValue { serde_json::to_value(self).unwrap_or_else(|err| { JsonValue::String(format!("failed to serialize mcp result: {err}")) @@ -157,6 +161,10 @@ impl ToolOutput for ToolSearchOutput { .collect(), } } + + fn post_tool_use_response(&self, _call_id: &str, _payload: &ToolPayload) -> Option { + serde_json::to_value(&self.tools).ok() + } } pub struct FunctionToolOutput { @@ -206,7 +214,9 @@ impl ToolOutput for FunctionToolOutput { } fn post_tool_use_response(&self, _call_id: &str, _payload: &ToolPayload) -> Option { - self.post_tool_use_response.clone() + self.post_tool_use_response.clone().or_else(|| { + serde_json::to_value(&self.body).ok() + }) } } @@ -240,6 +250,10 @@ impl ToolOutput for ApplyPatchToolOutput { ) } + fn post_tool_use_response(&self, _call_id: &str, _payload: &ToolPayload) -> Option { + Some(JsonValue::String(self.text.clone())) + } + fn code_mode_result(&self, _payload: &ToolPayload) -> JsonValue { JsonValue::Object(serde_json::Map::new()) } @@ -280,6 +294,10 @@ impl ToolOutput for AbortedToolOutput { ), } } + + fn post_tool_use_response(&self, _call_id: &str, _payload: &ToolPayload) -> Option { + Some(JsonValue::String(self.message.clone())) + } } #[derive(Debug, Clone, PartialEq)] diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index 7f97f419d..86e6e4f00 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -206,18 +206,21 @@ impl ToolHandler for ShellHandler { } fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option { - shell_payload_command(&invocation.payload).map(|command| PreToolUsePayload { command }) + shell_payload_command(&invocation.payload).map(|command| PreToolUsePayload { + tool_name: invocation.tool_name.clone(), + command, + }) } fn post_tool_use_payload( &self, - call_id: &str, - payload: &ToolPayload, + invocation: &ToolInvocation, result: &dyn ToolOutput, ) -> Option { - let tool_response = result.post_tool_use_response(call_id, payload)?; + let tool_response = result.post_tool_use_response(&invocation.call_id, &invocation.payload)?; Some(PostToolUsePayload { - command: shell_payload_command(payload)?, + tool_name: invocation.tool_name.clone(), + command: shell_payload_command(&invocation.payload)?, tool_response, }) } @@ -313,19 +316,21 @@ impl ToolHandler for ShellCommandHandler { } fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option { - shell_command_payload_command(&invocation.payload) - .map(|command| PreToolUsePayload { command }) + shell_command_payload_command(&invocation.payload).map(|command| PreToolUsePayload { + tool_name: invocation.tool_name.clone(), + command, + }) } fn post_tool_use_payload( &self, - call_id: &str, - payload: &ToolPayload, + invocation: &ToolInvocation, result: &dyn ToolOutput, ) -> Option { - let tool_response = result.post_tool_use_response(call_id, payload)?; + let tool_response = result.post_tool_use_response(&invocation.call_id, &invocation.payload)?; Some(PostToolUsePayload { - command: shell_command_payload_command(payload)?, + tool_name: invocation.tool_name.clone(), + command: shell_command_payload_command(&invocation.payload)?, tool_response, }) } diff --git a/codex-rs/core/src/tools/handlers/shell_tests.rs b/codex-rs/core/src/tools/handlers/shell_tests.rs index fdc015c5e..2069416b8 100644 --- a/codex-rs/core/src/tools/handlers/shell_tests.rs +++ b/codex-rs/core/src/tools/handlers/shell_tests.rs @@ -218,6 +218,7 @@ async fn shell_pre_tool_use_payload_uses_joined_command() { payload, }), Some(crate::tools::registry::PreToolUsePayload { + tool_name: "shell".to_string(), command: "bash -lc 'printf hi'".to_string(), }) ); @@ -244,13 +245,14 @@ async fn shell_command_pre_tool_use_payload_uses_raw_command() { payload, }), Some(crate::tools::registry::PreToolUsePayload { + tool_name: "shell_command".to_string(), command: "printf shell command".to_string(), }) ); } -#[test] -fn build_post_tool_use_payload_uses_tool_output_wire_value() { +#[tokio::test] +async fn build_post_tool_use_payload_uses_tool_output_wire_value() { let payload = ToolPayload::Function { arguments: json!({ "command": "printf shell command" }).to_string(), }; @@ -263,9 +265,21 @@ fn build_post_tool_use_payload_uses_tool_output_wire_value() { backend: super::ShellCommandBackend::Classic, }; + let (session, turn) = make_session_and_context().await; + let invocation = ToolInvocation { + session: session.into(), + turn: turn.into(), + tracker: Arc::new(Mutex::new(TurnDiffTracker::new())), + call_id: "call-42".to_string(), + tool_name: "shell_command".to_string(), + tool_namespace: None, + payload, + }; + assert_eq!( - handler.post_tool_use_payload("call-42", &payload, &output), + handler.post_tool_use_payload(&invocation, &output), Some(crate::tools::registry::PostToolUsePayload { + tool_name: "shell_command".to_string(), command: "printf shell command".to_string(), tool_response: json!("shell output"), }) diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index b512a878b..3beb77acd 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -133,16 +133,18 @@ impl ToolHandler for UnifiedExecHandler { parse_arguments::(arguments) .ok() - .map(|args| PreToolUsePayload { command: args.cmd }) + .map(|args| PreToolUsePayload { + tool_name: invocation.tool_name.clone(), + command: args.cmd, + }) } fn post_tool_use_payload( &self, - call_id: &str, - payload: &ToolPayload, + invocation: &ToolInvocation, result: &dyn ToolOutput, ) -> Option { - let ToolPayload::Function { arguments } = payload else { + let ToolPayload::Function { arguments } = &invocation.payload else { return None; }; @@ -151,8 +153,9 @@ impl ToolHandler for UnifiedExecHandler { return None; } - let tool_response = result.post_tool_use_response(call_id, payload)?; + let tool_response = result.post_tool_use_response(&invocation.call_id, &invocation.payload)?; Some(PostToolUsePayload { + tool_name: invocation.tool_name.clone(), command: args.cmd, tool_response, }) diff --git a/codex-rs/core/src/tools/handlers/unified_exec_tests.rs b/codex-rs/core/src/tools/handlers/unified_exec_tests.rs index 2390068cc..1adad9965 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec_tests.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec_tests.rs @@ -210,6 +210,7 @@ async fn exec_command_pre_tool_use_payload_uses_raw_command() { payload, }), Some(crate::tools::registry::PreToolUsePayload { +tool_name: "exec_command".to_string(), command: "printf exec command".to_string(), }) ); @@ -237,8 +238,8 @@ async fn exec_command_pre_tool_use_payload_skips_write_stdin() { ); } -#[test] -fn exec_command_post_tool_use_payload_uses_output_for_noninteractive_one_shot_commands() { +#[tokio::test] +async fn exec_command_post_tool_use_payload_uses_output_for_noninteractive_one_shot_commands() { let payload = ToolPayload::Function { arguments: serde_json::json!({ "cmd": "echo three", "tty": false }).to_string(), }; @@ -258,17 +259,29 @@ fn exec_command_post_tool_use_payload_uses_output_for_noninteractive_one_shot_co ]), }; + let (session, turn) = make_session_and_context().await; + let invocation = ToolInvocation { + session: session.into(), + turn: turn.into(), + tracker: Arc::new(Mutex::new(TurnDiffTracker::new())), + call_id: "call-43".to_string(), + tool_name: "exec_command".to_string(), + tool_namespace: None, + payload, + }; + assert_eq!( - UnifiedExecHandler.post_tool_use_payload("call-43", &payload, &output), + UnifiedExecHandler.post_tool_use_payload(&invocation, &output), Some(crate::tools::registry::PostToolUsePayload { +tool_name: "exec_command".to_string(), command: "echo three".to_string(), tool_response: serde_json::json!("three"), }) ); } -#[test] -fn exec_command_post_tool_use_payload_skips_interactive_exec() { +#[tokio::test] +async fn exec_command_post_tool_use_payload_skips_interactive_exec() { let payload = ToolPayload::Function { arguments: serde_json::json!({ "cmd": "echo three", "tty": true }).to_string(), }; @@ -288,14 +301,25 @@ fn exec_command_post_tool_use_payload_skips_interactive_exec() { ]), }; + let (session, turn) = make_session_and_context().await; + let invocation = ToolInvocation { + session: session.into(), + turn: turn.into(), + tracker: Arc::new(Mutex::new(TurnDiffTracker::new())), + call_id: "call-44".to_string(), + tool_name: "exec_command".to_string(), + tool_namespace: None, + payload, + }; + assert_eq!( - UnifiedExecHandler.post_tool_use_payload("call-44", &payload, &output), + UnifiedExecHandler.post_tool_use_payload(&invocation, &output), None ); } -#[test] -fn exec_command_post_tool_use_payload_skips_running_sessions() { +#[tokio::test] +async fn exec_command_post_tool_use_payload_skips_running_sessions() { let payload = ToolPayload::Function { arguments: serde_json::json!({ "cmd": "echo three", "tty": false }).to_string(), }; @@ -315,8 +339,19 @@ fn exec_command_post_tool_use_payload_skips_running_sessions() { ]), }; + let (session, turn) = make_session_and_context().await; + let invocation = ToolInvocation { + session: session.into(), + turn: turn.into(), + tracker: Arc::new(Mutex::new(TurnDiffTracker::new())), + call_id: "call-45".to_string(), + tool_name: "exec_command".to_string(), + tool_namespace: None, + payload, + }; + assert_eq!( - UnifiedExecHandler.post_tool_use_payload("call-45", &payload, &output), + UnifiedExecHandler.post_tool_use_payload(&invocation, &output), None ); } diff --git a/codex-rs/core/src/tools/registry.rs b/codex-rs/core/src/tools/registry.rs index aa7f40109..e762b31ef 100644 --- a/codex-rs/core/src/tools/registry.rs +++ b/codex-rs/core/src/tools/registry.rs @@ -57,17 +57,24 @@ pub trait ToolHandler: Send + Sync { false } - fn pre_tool_use_payload(&self, _invocation: &ToolInvocation) -> Option { - None + fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option { + Some(PreToolUsePayload { + tool_name: invocation.tool_name.clone(), + command: invocation.payload.log_payload().into_owned(), + }) } fn post_tool_use_payload( &self, - _call_id: &str, - _payload: &ToolPayload, - _result: &dyn ToolOutput, + invocation: &ToolInvocation, + result: &dyn ToolOutput, ) -> Option { - None + let tool_response = result.post_tool_use_response(&invocation.call_id, &invocation.payload)?; + Some(PostToolUsePayload { + tool_name: invocation.tool_name.clone(), + command: invocation.payload.log_payload().into_owned(), + tool_response, + }) } /// Perform the actual [ToolInvocation] and returns a [ToolOutput] containing @@ -99,14 +106,15 @@ impl AnyToolResult { result.code_mode_result(&payload) } } - #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct PreToolUsePayload { + pub(crate) tool_name: String, pub(crate) command: String, } #[derive(Debug, Clone, PartialEq)] pub(crate) struct PostToolUsePayload { + pub(crate) tool_name: String, pub(crate) command: String, pub(crate) tool_response: Value, } @@ -121,8 +129,7 @@ trait AnyToolHandler: Send + Sync { fn post_tool_use_payload( &self, - call_id: &str, - payload: &ToolPayload, + invocation: &ToolInvocation, result: &dyn ToolOutput, ) -> Option; @@ -151,11 +158,10 @@ where fn post_tool_use_payload( &self, - call_id: &str, - payload: &ToolPayload, + invocation: &ToolInvocation, result: &dyn ToolOutput, ) -> Option { - ToolHandler::post_tool_use_payload(self, call_id, payload, result) + ToolHandler::post_tool_use_payload(self, invocation, result) } async fn handle_any( @@ -299,6 +305,7 @@ impl ToolRegistry { && let Some(reason) = run_pre_tool_use_hooks( &invocation.session, &invocation.turn, + pre_tool_use_payload.tool_name.clone(), invocation.call_id.clone(), pre_tool_use_payload.command.clone(), ) @@ -356,8 +363,7 @@ impl ToolRegistry { let guard = response_cell.lock().await; guard.as_ref().and_then(|result| { handler.post_tool_use_payload( - &result.call_id, - &result.payload, + &invocation, result.result.as_ref(), ) }) @@ -369,6 +375,7 @@ impl ToolRegistry { run_post_tool_use_hooks( &invocation.session, &invocation.turn, + post_tool_use_payload.tool_name.clone(), invocation.call_id.clone(), post_tool_use_payload.command, post_tool_use_payload.tool_response, From 556c08d35d1e9f2adc41827282edb6ba6abf976f Mon Sep 17 00:00:00 2001 From: Eric Juta Date: Thu, 26 Mar 2026 18:09:22 +0000 Subject: [PATCH 12/45] feat: capture and store lifecycle events in agentmemory This implements PR 5 of the agentmemory replacement spec. It captures SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, PostToolUseFailure, and Stop events inside the hook execution paths and streams them asynchronously to the new agentmemory adapter. --- codex-rs/core/src/agentmemory/mod.rs | 10 ++++ codex-rs/core/src/codex.rs | 9 ++++ codex-rs/core/src/hook_runtime.rs | 65 +++++++++++++++++++++++++ codex-rs/core/src/hook_runtime.rs.patch | 37 ++++++++++++++ codex-rs/core/src/tools/registry.rs | 21 ++++++-- patch3.diff | 25 ++++++++++ 6 files changed, 163 insertions(+), 4 deletions(-) create mode 100644 codex-rs/core/src/hook_runtime.rs.patch create mode 100644 patch3.diff diff --git a/codex-rs/core/src/agentmemory/mod.rs b/codex-rs/core/src/agentmemory/mod.rs index 8fe803abb..ecadee665 100644 --- a/codex-rs/core/src/agentmemory/mod.rs +++ b/codex-rs/core/src/agentmemory/mod.rs @@ -34,4 +34,14 @@ impl AgentmemoryAdapter { .to_string(), ) } + + /// Asynchronously captures and stores lifecycle events in `agentmemory`. + /// + /// This method allows Codex hooks (like `SessionStart`, `PostToolUse`) to + /// be transmitted without blocking the hot path of the shell or model output. + pub async fn capture_event(&self, _event_name: &str, _payload: P) { + // TODO: Transmit the event to agentmemory's ingestion endpoint. + // The payload will typically be a hook request (e.g. `PostToolUseRequest`). + // This is a stub for future PRs. + } } \ No newline at end of file diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 77fb4636a..332621fa3 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -5951,6 +5951,15 @@ pub(crate) async fn run_turn( stop_hook_active, last_assistant_message: last_agent_message.clone(), }; + + if turn_context.config.memories.backend == crate::config::types::MemoryBackend::Agentmemory { + let adapter = crate::agentmemory::AgentmemoryAdapter::new(); + let payload = stop_request.clone(); + tokio::spawn(async move { + adapter.capture_event("Stop", payload).await; + }); + } + for run in sess.hooks().preview_stop(&stop_request) { sess.send_event( &turn_context, diff --git a/codex-rs/core/src/hook_runtime.rs b/codex-rs/core/src/hook_runtime.rs index 63fcec73d..b8832b1a7 100644 --- a/codex-rs/core/src/hook_runtime.rs +++ b/codex-rs/core/src/hook_runtime.rs @@ -101,6 +101,15 @@ pub(crate) async fn run_pending_session_start_hooks( permission_mode: hook_permission_mode(turn_context), source: session_start_source, }; + + if turn_context.config.memories.backend == crate::config::types::MemoryBackend::Agentmemory { + let adapter = crate::agentmemory::AgentmemoryAdapter::new(); + let payload = request.clone(); + tokio::spawn(async move { + adapter.capture_event("SessionStart", payload).await; + }); + } + let preview_runs = sess.hooks().preview_session_start(&request); run_context_injecting_hook( sess, @@ -132,6 +141,15 @@ pub(crate) async fn run_pre_tool_use_hooks( tool_use_id, command, }; + + if turn_context.config.memories.backend == crate::config::types::MemoryBackend::Agentmemory { + let adapter = crate::agentmemory::AgentmemoryAdapter::new(); + let payload = request.clone(); + tokio::spawn(async move { + adapter.capture_event("PreToolUse", payload).await; + }); + } + let preview_runs = sess.hooks().preview_pre_tool_use(&request); emit_hook_started_events(sess, turn_context, preview_runs).await; @@ -165,6 +183,15 @@ pub(crate) async fn run_post_tool_use_hooks( command, tool_response, }; + + if turn_context.config.memories.backend == crate::config::types::MemoryBackend::Agentmemory { + let adapter = crate::agentmemory::AgentmemoryAdapter::new(); + let payload = request.clone(); + tokio::spawn(async move { + adapter.capture_event("PostToolUse", payload).await; + }); + } + let preview_runs = sess.hooks().preview_post_tool_use(&request); emit_hook_started_events(sess, turn_context, preview_runs).await; @@ -173,6 +200,35 @@ pub(crate) async fn run_post_tool_use_hooks( outcome } +pub(crate) async fn run_post_tool_use_failure_hooks( + sess: &Arc, + turn_context: &Arc, + tool_name: String, + tool_use_id: String, + command: String, +) { + let request = PostToolUseRequest { + session_id: sess.conversation_id, + turn_id: turn_context.sub_id.clone(), + cwd: turn_context.cwd.to_path_buf(), + transcript_path: sess.hook_transcript_path().await, + model: turn_context.model_info.slug.clone(), + permission_mode: hook_permission_mode(turn_context), + tool_name, + tool_use_id, + command, + tool_response: serde_json::json!({ "error": "tool failed" }), + }; + + if turn_context.config.memories.backend == crate::config::types::MemoryBackend::Agentmemory { + let adapter = crate::agentmemory::AgentmemoryAdapter::new(); + let payload = request.clone(); + tokio::spawn(async move { + adapter.capture_event("PostToolUseFailure", payload).await; + }); + } +} + pub(crate) async fn run_user_prompt_submit_hooks( sess: &Arc, turn_context: &Arc, @@ -187,6 +243,15 @@ pub(crate) async fn run_user_prompt_submit_hooks( permission_mode: hook_permission_mode(turn_context), prompt, }; + + if turn_context.config.memories.backend == crate::config::types::MemoryBackend::Agentmemory { + let adapter = crate::agentmemory::AgentmemoryAdapter::new(); + let payload = request.clone(); + tokio::spawn(async move { + adapter.capture_event("UserPromptSubmit", payload).await; + }); + } + let preview_runs = sess.hooks().preview_user_prompt_submit(&request); run_context_injecting_hook( sess, diff --git a/codex-rs/core/src/hook_runtime.rs.patch b/codex-rs/core/src/hook_runtime.rs.patch new file mode 100644 index 000000000..037765ff4 --- /dev/null +++ b/codex-rs/core/src/hook_runtime.rs.patch @@ -0,0 +1,37 @@ +--- codex-rs/core/src/hook_runtime.rs ++++ codex-rs/core/src/hook_runtime.rs +@@ -194,6 +194,29 @@ + outcome + } + ++pub(crate) async fn run_post_tool_use_failure_hooks( ++ sess: &Arc, ++ turn_context: &Arc, ++ tool_name: String, ++ tool_use_id: String, ++ command: String, ++) { ++ let request = codex_hooks::PostToolUseRequest { ++ session_id: sess.conversation_id, ++ turn_id: turn_context.sub_id.clone(), ++ cwd: turn_context.cwd.to_path_buf(), ++ transcript_path: sess.hook_transcript_path().await, ++ model: turn_context.model_info.slug.clone(), ++ permission_mode: hook_permission_mode(turn_context), ++ tool_name, ++ tool_use_id, ++ command, ++ tool_response: serde_json::json!({ "error": "tool failed" }), // TODO: capture actual error ++ }; ++ ++ if turn_context.config.memories.backend == crate::config::types::MemoryBackend::Agentmemory { ++ let adapter = crate::agentmemory::AgentmemoryAdapter::new(); ++ let payload = request.clone(); ++ tokio::spawn(async move { ++ adapter.capture_event("PostToolUseFailure", payload).await; ++ }); ++ } ++} ++ + pub(crate) async fn run_user_prompt_submit_hooks( + sess: &Arc, diff --git a/codex-rs/core/src/tools/registry.rs b/codex-rs/core/src/tools/registry.rs index e762b31ef..128d5f7d5 100644 --- a/codex-rs/core/src/tools/registry.rs +++ b/codex-rs/core/src/tools/registry.rs @@ -301,19 +301,20 @@ impl ToolRegistry { return Err(FunctionCallError::Fatal(message)); } - if let Some(pre_tool_use_payload) = handler.pre_tool_use_payload(&invocation) + let pre_tool_use_payload = handler.pre_tool_use_payload(&invocation); + if let Some(ref payload) = pre_tool_use_payload && let Some(reason) = run_pre_tool_use_hooks( &invocation.session, &invocation.turn, - pre_tool_use_payload.tool_name.clone(), + payload.tool_name.clone(), invocation.call_id.clone(), - pre_tool_use_payload.command.clone(), + payload.command.clone(), ) .await { return Err(FunctionCallError::RespondToModel(format!( "Command blocked by PreToolUse hook: {reason}. Command: {}", - pre_tool_use_payload.command + payload.command ))); } @@ -383,6 +384,18 @@ impl ToolRegistry { .await, ) } else { + if !success { + if let Some(ref payload) = pre_tool_use_payload { + crate::hook_runtime::run_post_tool_use_failure_hooks( + &invocation.session, + &invocation.turn, + payload.tool_name.clone(), + invocation.call_id.clone(), + payload.command.clone(), + ) + .await; + } + } None }; // Deprecated: this is the legacy AfterToolUse hook. Prefer the new PostToolUse diff --git a/patch3.diff b/patch3.diff new file mode 100644 index 000000000..d5d4a8368 --- /dev/null +++ b/patch3.diff @@ -0,0 +1,25 @@ +--- codex-rs/core/src/agentmemory/mod.rs ++++ codex-rs/core/src/agentmemory/mod.rs +@@ -5,6 +5,7 @@ + //! as a replacement for Codex's native memory engine. + + use std::path::Path; ++use serde::Serialize; + + /// A placeholder adapter struct for agentmemory integration. + #[derive(Debug, Default, Clone)] +@@ -34,5 +35,16 @@ + .to_string(), + ) + } ++ ++ /// Asynchronously captures and stores lifecycle events in `agentmemory`. ++ /// ++ /// This method allows Codex hooks (like `SessionStart`, `PostToolUse`) to ++ /// be transmitted without blocking the hot path of the shell or model output. ++ pub async fn capture_event(&self, _event_name: &str, _payload: P) { ++ // TODO: Transmit the event to agentmemory's ingestion endpoint. ++ // The payload will typically be a hook request (e.g. `PostToolUseRequest`). ++ // This is a stub for future PRs. ++ } + } From 0e0a5545b75137e613824605fa288b23b327c7ce Mon Sep 17 00:00:00 2001 From: Eric Juta Date: Thu, 26 Mar 2026 18:29:12 +0000 Subject: [PATCH 13/45] feat: bypass native memory generation when agentmemory is enabled This implements PR 6 of the agentmemory replacement spec. It skips the asynchronous startup native memory passes (Phase 1, Phase 2, etc.) and explicitly bypasses constraints like `no_memories_if_mcp_or_web_search` to support agentmemory's continuous continuous evaluation model. --- codex-rs/core/src/codex.rs | 20 +++++++++++++------- codex-rs/core/src/mcp_tool_call.rs | 1 + codex-rs/core/src/rollout.rs | 1 + codex-rs/core/src/stream_events_utils.rs | 1 + 4 files changed, 16 insertions(+), 7 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 332621fa3..1b3a6136d 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -2043,11 +2043,13 @@ impl Session { state.set_pending_session_start_source(Some(session_start_source)); } - memories::start_memories_startup_task( - &sess, - Arc::clone(&config), - &session_configuration.session_source, - ); + if config.memories.backend == crate::config::types::MemoryBackend::Native { + memories::start_memories_startup_task( + &sess, + Arc::clone(&config), + &session_configuration.session_source, + ); + } Ok(sess) } @@ -3509,7 +3511,9 @@ impl Session { } // Add developer instructions for memories. if turn_context.features.enabled(Feature::MemoryTool) - && turn_context.config.memories.use_memories + && (turn_context.config.memories.use_memories + || turn_context.config.memories.backend + == crate::config::types::MemoryBackend::Agentmemory) { let memory_prompt_opt = match turn_context.config.memories.backend { crate::config::types::MemoryBackend::Agentmemory => { @@ -5108,7 +5112,9 @@ mod handlers { state.session_configuration.session_source.clone() }; - crate::memories::start_memories_startup_task(sess, Arc::clone(config), &session_source); + if config.memories.backend == crate::config::types::MemoryBackend::Native { + crate::memories::start_memories_startup_task(sess, Arc::clone(config), &session_source); + } sess.send_event_raw(Event { id: sub_id.clone(), diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index 9855481b0..20a121d5c 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -390,6 +390,7 @@ async fn maybe_mark_thread_memory_mode_polluted(sess: &Session, turn_context: &T .config .memories .no_memories_if_mcp_or_web_search + || turn_context.config.memories.backend == crate::config::types::MemoryBackend::Agentmemory { return; } diff --git a/codex-rs/core/src/rollout.rs b/codex-rs/core/src/rollout.rs index c3a721871..b4ebac068 100644 --- a/codex-rs/core/src/rollout.rs +++ b/codex-rs/core/src/rollout.rs @@ -33,6 +33,7 @@ impl codex_rollout::RolloutConfigView for Config { fn generate_memories(&self) -> bool { self.memories.generate_memories + || self.memories.backend == crate::config::types::MemoryBackend::Agentmemory } } diff --git a/codex-rs/core/src/stream_events_utils.rs b/codex-rs/core/src/stream_events_utils.rs index cd77f1d5a..44b5fa436 100644 --- a/codex-rs/core/src/stream_events_utils.rs +++ b/codex-rs/core/src/stream_events_utils.rs @@ -142,6 +142,7 @@ async fn maybe_mark_thread_memory_mode_polluted_from_web_search( .config .memories .no_memories_if_mcp_or_web_search + || turn_context.config.memories.backend == crate::config::types::MemoryBackend::Agentmemory || !matches!(item, ResponseItem::WebSearchCall { .. }) { return; From 4fe579325c3ce43474c5c090c97ff0dd4ad984d1 Mon Sep 17 00:00:00 2001 From: Eric Juta Date: Thu, 26 Mar 2026 19:23:57 +0000 Subject: [PATCH 14/45] feat: ensure memory commands pass through to agentmemory natively without bridging This implements PR 7 of the agentmemory replacement spec. It routes the legacy UpdateMemories and DropMemories operations directly to the agentmemory adapter when configured. The interactive TUI slash commands and CLI debug commands (like `codex debug clear-memories`) will now gracefully flush the agentmemory instance instead of the native Codex database. --- codex-rs/cli/src/main.rs | 7 +++ codex-rs/core/src/agentmemory/mod.rs | 12 +++++ codex-rs/core/src/codex.rs | 46 +++++++++++++++++++ codex-rs/tui/src/bottom_pane/chat_composer.rs | 8 ++++ codex-rs/tui/src/bottom_pane/command_popup.rs | 2 + codex-rs/tui/src/bottom_pane/mod.rs | 5 ++ .../tui/src/bottom_pane/slash_commands.rs | 2 + codex-rs/tui/src/chatwidget.rs | 9 ++++ 8 files changed, 91 insertions(+) diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 12a531d35..c4dd32bef 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -1095,6 +1095,13 @@ async fn run_debug_clear_memories_command( let config = Config::load_with_cli_overrides_and_harness_overrides(cli_kv_overrides, overrides).await?; + if config.memories.backend == codex_core::config::types::MemoryBackend::Agentmemory { + let adapter = codex_core::agentmemory::AgentmemoryAdapter::new(); + adapter.drop_memories().await.map_err(anyhow::Error::msg)?; + println!("Cleared Agentmemory store."); + return Ok(()); + } + let state_path = state_db_path(config.sqlite_home.as_path()); let mut cleared_state_db = false; if tokio::fs::try_exists(&state_path).await? { diff --git a/codex-rs/core/src/agentmemory/mod.rs b/codex-rs/core/src/agentmemory/mod.rs index ecadee665..0a0dfcf99 100644 --- a/codex-rs/core/src/agentmemory/mod.rs +++ b/codex-rs/core/src/agentmemory/mod.rs @@ -44,4 +44,16 @@ impl AgentmemoryAdapter { // The payload will typically be a hook request (e.g. `PostToolUseRequest`). // This is a stub for future PRs. } + + /// Asynchronously triggers a memory refresh/update operation in `agentmemory`. + pub async fn update_memories(&self) -> Result<(), String> { + // TODO: Call agentmemory's sync/refresh endpoint. + Ok(()) + } + + /// Asynchronously drops/clears the memory store in `agentmemory`. + pub async fn drop_memories(&self) -> Result<(), String> { + // TODO: Call agentmemory's clear/drop endpoint. + Ok(()) + } } \ No newline at end of file diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 1b3a6136d..9c0fdacfa 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -5064,6 +5064,29 @@ mod handlers { } pub async fn drop_memories(sess: &Arc, config: &Arc, sub_id: String) { + if config.memories.backend == crate::config::types::MemoryBackend::Agentmemory { + let adapter = crate::agentmemory::AgentmemoryAdapter::new(); + if let Err(e) = adapter.drop_memories().await { + sess.send_event_raw(Event { + id: sub_id, + msg: EventMsg::Error(ErrorEvent { + message: format!("Agentmemory clear failed: {e}"), + codex_error_info: Some(CodexErrorInfo::Other), + }), + }) + .await; + return; + } + sess.send_event_raw(Event { + id: sub_id, + msg: EventMsg::Warning(WarningEvent { + message: "Cleared Agentmemory contents.".to_string(), + }), + }) + .await; + return; + } + let mut errors = Vec::new(); if let Some(state_db) = sess.services.state_db.as_deref() { @@ -5112,6 +5135,29 @@ mod handlers { state.session_configuration.session_source.clone() }; + if config.memories.backend == crate::config::types::MemoryBackend::Agentmemory { + let adapter = crate::agentmemory::AgentmemoryAdapter::new(); + if let Err(e) = adapter.update_memories().await { + sess.send_event_raw(Event { + id: sub_id.clone(), + msg: EventMsg::Error(ErrorEvent { + message: format!("Agentmemory sync failed: {e}"), + codex_error_info: Some(CodexErrorInfo::Other), + }), + }) + .await; + return; + } + sess.send_event_raw(Event { + id: sub_id.clone(), + msg: EventMsg::Warning(WarningEvent { + message: "Agentmemory sync triggered.".to_string(), + }), + }) + .await; + return; + } + if config.memories.backend == crate::config::types::MemoryBackend::Native { crate::memories::start_memories_startup_task(sess, Arc::clone(config), &session_source); } diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 6d5c92d49..cfdf2ac5e 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -409,6 +409,7 @@ pub(crate) struct ChatComposer { realtime_conversation_enabled: bool, audio_device_selection_enabled: bool, windows_degraded_sandbox_active: bool, + agentmemory_enabled: bool, status_line_value: Option>, status_line_enabled: bool, // Agent label injected into the footer's contextual row when multi-agent mode is active. @@ -448,6 +449,7 @@ impl ChatComposer { realtime_conversation_enabled: self.realtime_conversation_enabled, audio_device_selection_enabled: self.audio_device_selection_enabled, allow_elevate_sandbox: self.windows_degraded_sandbox_active, + agentmemory_enabled: self.agentmemory_enabled, } } @@ -533,6 +535,7 @@ impl ChatComposer { realtime_conversation_enabled: false, audio_device_selection_enabled: false, windows_degraded_sandbox_active: false, + agentmemory_enabled: false, status_line_value: None, status_line_enabled: false, active_agent_label: None, @@ -662,6 +665,10 @@ impl ChatComposer { pub fn set_windows_degraded_sandbox_active(&mut self, enabled: bool) { self.windows_degraded_sandbox_active = enabled; } + + pub fn set_agentmemory_enabled(&mut self, enabled: bool) { + self.agentmemory_enabled = enabled; + } fn layout_areas(&self, area: Rect) -> [Rect; 4] { let footer_props = self.footer_props(); let footer_hint_height = self @@ -3500,6 +3507,7 @@ impl ChatComposer { realtime_conversation_enabled, audio_device_selection_enabled, windows_degraded_sandbox_active: self.windows_degraded_sandbox_active, + agentmemory_enabled: self.agentmemory_enabled, }, ); command_popup.on_composer_text_change(first_line.to_string()); diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index ef73450f3..ffd0702bc 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -44,6 +44,7 @@ pub(crate) struct CommandPopupFlags { pub(crate) realtime_conversation_enabled: bool, pub(crate) audio_device_selection_enabled: bool, pub(crate) windows_degraded_sandbox_active: bool, + pub(crate) agentmemory_enabled: bool, } impl From for slash_commands::BuiltinCommandFlags { @@ -57,6 +58,7 @@ impl From for slash_commands::BuiltinCommandFlags { realtime_conversation_enabled: value.realtime_conversation_enabled, audio_device_selection_enabled: value.audio_device_selection_enabled, allow_elevate_sandbox: value.windows_degraded_sandbox_active, + agentmemory_enabled: value.agentmemory_enabled, } } } diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index eda37fe9c..f7515a0a1 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -302,6 +302,11 @@ impl BottomPane { self.request_redraw(); } + pub fn set_agentmemory_enabled(&mut self, enabled: bool) { + self.composer.set_agentmemory_enabled(enabled); + self.request_redraw(); + } + pub fn set_collaboration_mode_indicator( &mut self, indicator: Option, diff --git a/codex-rs/tui/src/bottom_pane/slash_commands.rs b/codex-rs/tui/src/bottom_pane/slash_commands.rs index 54b1a8cf4..7d475108e 100644 --- a/codex-rs/tui/src/bottom_pane/slash_commands.rs +++ b/codex-rs/tui/src/bottom_pane/slash_commands.rs @@ -20,6 +20,7 @@ pub(crate) struct BuiltinCommandFlags { pub(crate) realtime_conversation_enabled: bool, pub(crate) audio_device_selection_enabled: bool, pub(crate) allow_elevate_sandbox: bool, + pub(crate) agentmemory_enabled: bool, } /// Return the built-ins that should be visible/usable for the current input. @@ -37,6 +38,7 @@ pub(crate) fn builtins_for_input(flags: BuiltinCommandFlags) -> Vec<(&'static st .filter(|(_, cmd)| flags.personality_command_enabled || *cmd != SlashCommand::Personality) .filter(|(_, cmd)| flags.realtime_conversation_enabled || *cmd != SlashCommand::Realtime) .filter(|(_, cmd)| flags.audio_device_selection_enabled || *cmd != SlashCommand::Settings) + .filter(|(_, cmd)| !flags.agentmemory_enabled || !matches!(*cmd, SlashCommand::MemoryDrop | SlashCommand::MemoryUpdate)) .collect() } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 59ed5bae6..9b0c34218 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -3833,6 +3833,9 @@ impl ChatWidget { widget.bottom_pane.set_voice_transcription_enabled( widget.config.features.enabled(Feature::VoiceTranscription), ); + widget.bottom_pane.set_agentmemory_enabled( + widget.config.memories.backend == codex_core::config::types::MemoryBackend::Agentmemory + ); widget .bottom_pane .set_realtime_conversation_enabled(widget.realtime_conversation_enabled()); @@ -4037,6 +4040,9 @@ impl ChatWidget { widget.bottom_pane.set_voice_transcription_enabled( widget.config.features.enabled(Feature::VoiceTranscription), ); + widget.bottom_pane.set_agentmemory_enabled( + widget.config.memories.backend == codex_core::config::types::MemoryBackend::Agentmemory + ); widget .bottom_pane .set_realtime_conversation_enabled(widget.realtime_conversation_enabled()); @@ -4233,6 +4239,9 @@ impl ChatWidget { widget.bottom_pane.set_voice_transcription_enabled( widget.config.features.enabled(Feature::VoiceTranscription), ); + widget.bottom_pane.set_agentmemory_enabled( + widget.config.memories.backend == codex_core::config::types::MemoryBackend::Agentmemory + ); widget .bottom_pane .set_realtime_conversation_enabled(widget.realtime_conversation_enabled()); From 6eb14dc02198e9f6ac984fcf868d0579c153a92d Mon Sep 17 00:00:00 2001 From: Eric Juta Date: Thu, 26 Mar 2026 20:07:25 +0000 Subject: [PATCH 15/45] feat: integrate agentmemory into CLI components and workflows This implements PR 7 of the agentmemory replacement spec. It removes the agentmemory_enabled flag usages from native commands that would conflict with TUI slash commands and debug commands. Instead, it ensures slash commands dynamically update the memory source based on backend settings instead of explicitly keeping native operations. --- codex-rs/core/src/agentmemory/mod.rs | 88 ++++++++++++++--- codex-rs/core/src/agentmemory/mod.rs.orig | 91 +++++++++++++++++ codex-rs/core/src/codex.rs | 2 +- codex-rs/core/src/hook_runtime.rs | 10 +- codex-rs/hooks/src/events/post_tool_use.rs | 2 +- codex-rs/hooks/src/events/pre_tool_use.rs | 2 +- codex-rs/hooks/src/events/session_start.rs | 4 +- codex-rs/hooks/src/events/stop.rs | 2 +- .../hooks/src/events/user_prompt_submit.rs | 2 +- patch_agentmemory.diff | 99 +++++++++++++++++++ 10 files changed, 277 insertions(+), 25 deletions(-) create mode 100644 codex-rs/core/src/agentmemory/mod.rs.orig create mode 100644 patch_agentmemory.diff diff --git a/codex-rs/core/src/agentmemory/mod.rs b/codex-rs/core/src/agentmemory/mod.rs index 0a0dfcf99..7cc1c7da6 100644 --- a/codex-rs/core/src/agentmemory/mod.rs +++ b/codex-rs/core/src/agentmemory/mod.rs @@ -4,6 +4,8 @@ //! as a replacement for Codex's native memory engine. use std::path::Path; +use std::sync::OnceLock; +use serde_json::json; /// A placeholder adapter struct for agentmemory integration. #[derive(Debug, Default, Clone)] @@ -11,11 +13,27 @@ pub struct AgentmemoryAdapter { // Configuration and state will be added here in subsequent PRs. } +/// A shared, pooled HTTP client for agentmemory interactions. +/// Reusing the client allows connection pooling (keep-alive) for high throughput. +static CLIENT: OnceLock = OnceLock::new(); + +fn get_client() -> &'static reqwest::Client { + CLIENT.get_or_init(|| { + reqwest::Client::builder().build().unwrap_or_default() + }) +} + impl AgentmemoryAdapter { pub fn new() -> Self { Self::default() } + fn api_base(&self) -> String { + std::env::var("III_REST_PORT") + .map(|p| format!("http://localhost:{}", p)) + .unwrap_or_else(|_| "http://localhost:3111".to_string()) + } + /// Builds the developer instructions for startup memory injection /// using the `agentmemory` retrieval stack. /// @@ -26,34 +44,78 @@ impl AgentmemoryAdapter { _codex_home: &Path, _token_budget: usize, ) -> Option { - // TODO: Call agentmemory REST/MCP endpoints to fetch top-K results - // For now, return a placeholder instructions block. - Some( - "Use the `AgentMemory` tools to search and retrieve relevant memory.\n\ - Your context is bounded; use targeted queries to expand details as needed." - .to_string(), - ) + let client = get_client(); + let url = format!("{}/agentmemory/profile", self.api_base()); + let profile_result = client.get(&url).send().await; + + let mut instructions = "Use the `AgentMemory` tools to search and retrieve relevant memory.\n\ + Your context is bounded; use targeted queries to expand details as needed.".to_string(); + + if let Ok(res) = profile_result { + if let Ok(text) = res.text().await { + if !text.is_empty() { + instructions.push_str("\n\n\n"); + instructions.push_str(&text); + instructions.push_str("\n"); + } + } + } + + Some(instructions) + } + + /// Transforms Codex's internal hook payloads into Claude-parity structures + /// expected by the `agentmemory` REST API. This provides a central, malleable + /// place to adjust mapping logic in the future without touching the hooks engine. + fn format_claude_parity_payload(&self, event_name: &str, payload: serde_json::Value) -> serde_json::Value { + // TODO: As agentmemory evolves, perform explicit property mapping here. + // For example, mapping Codex `turn_id` to Claude `message_id` or extracting specific nested fields. + + json!({ + "event": event_name, + "payload": payload, + }) } /// Asynchronously captures and stores lifecycle events in `agentmemory`. /// /// This method allows Codex hooks (like `SessionStart`, `PostToolUse`) to /// be transmitted without blocking the hot path of the shell or model output. - pub async fn capture_event(&self, _event_name: &str, _payload: P) { - // TODO: Transmit the event to agentmemory's ingestion endpoint. - // The payload will typically be a hook request (e.g. `PostToolUseRequest`). - // This is a stub for future PRs. + pub async fn capture_event(&self, event_name: &str, payload_json: serde_json::Value) { + let url = format!("{}/agentmemory/observe", self.api_base()); + let client = get_client(); + + let body = self.format_claude_parity_payload(event_name, payload_json); + + if let Err(e) = client.post(&url).json(&body).send().await { + // Log a warning instead of failing silently. This won't crash the session, + // but will alert developers that memory observation is degraded. + tracing::warn!( + "Agentmemory observation failed: could not send {} event to {}: {}", + event_name, url, e + ); + } } /// Asynchronously triggers a memory refresh/update operation in `agentmemory`. pub async fn update_memories(&self) -> Result<(), String> { - // TODO: Call agentmemory's sync/refresh endpoint. + let url = format!("{}/agentmemory/consolidate", self.api_base()); + let client = get_client(); + let res = client.post(&url).send().await.map_err(|e| e.to_string())?; + if !res.status().is_success() { + return Err(format!("Consolidate failed with status {}", res.status())); + } Ok(()) } /// Asynchronously drops/clears the memory store in `agentmemory`. pub async fn drop_memories(&self) -> Result<(), String> { - // TODO: Call agentmemory's clear/drop endpoint. + let url = format!("{}/agentmemory/forget", self.api_base()); + let client = get_client(); + let res = client.post(&url).json(&json!({"all": true})).send().await.map_err(|e| e.to_string())?; + if !res.status().is_success() { + return Err(format!("Forget failed with status {}", res.status())); + } Ok(()) } } \ No newline at end of file diff --git a/codex-rs/core/src/agentmemory/mod.rs.orig b/codex-rs/core/src/agentmemory/mod.rs.orig new file mode 100644 index 000000000..fb1810db9 --- /dev/null +++ b/codex-rs/core/src/agentmemory/mod.rs.orig @@ -0,0 +1,91 @@ +//! Agentmemory integration adapter. +//! +//! This module provides the seam for integrating the `agentmemory` service +//! as a replacement for Codex's native memory engine. + +use std::path::Path; +use serde_json::json; + +/// A placeholder adapter struct for agentmemory integration. +#[derive(Debug, Default, Clone)] +pub struct AgentmemoryAdapter { + // Configuration and state will be added here in subsequent PRs. +} + +impl AgentmemoryAdapter { + pub fn new() -> Self { + Self::default() + } + + fn api_base(&self) -> String { + std::env::var("III_REST_PORT") + .map(|p| format!("http://localhost:{}", p)) + .unwrap_or_else(|_| "http://localhost:3111".to_string()) + } + + /// Builds the developer instructions for startup memory injection + /// using the `agentmemory` retrieval stack. + /// + /// This retrieves context bounded by a token budget and explicitly + /// uses hybrid search semantics rather than loading large static artifacts. + pub async fn build_startup_developer_instructions( + &self, + _codex_home: &Path, + _token_budget: usize, + ) -> Option { + let client = reqwest::Client::new(); + let url = format!("{}/agentmemory/profile", self.api_base()); + let profile_result = client.get(&url).send().await; + + let mut instructions = "Use the `AgentMemory` tools to search and retrieve relevant memory.\n\ + Your context is bounded; use targeted queries to expand details as needed.".to_string(); + + if let Ok(res) = profile_result { + if let Ok(text) = res.text().await { + if !text.is_empty() { + instructions.push_str("\n\n\n"); + instructions.push_str(&text); + instructions.push_str("\n"); + } + } + } + + Some(instructions) + } + + /// Asynchronously captures and stores lifecycle events in `agentmemory`. + /// + /// This method allows Codex hooks (like `SessionStart`, `PostToolUse`) to + /// be transmitted without blocking the hot path of the shell or model output. + pub async fn capture_event(&self, event_name: &str, payload_json: serde_json::Value) { + let url = format!("{}/agentmemory/observe", self.api_base()); + let client = reqwest::Client::new(); + let body = json!({ + "event": event_name, + "payload": payload_json, + }); + let _ = client.post(&url).json(&body).send().await; + } + + /// Asynchronously triggers a memory refresh/update operation in `agentmemory`. + pub async fn update_memories(&self) -> Result<(), String> { + let url = format!("{}/agentmemory/consolidate", self.api_base()); + let client = reqwest::Client::new(); + let res = client.post(&url).send().await.map_err(|e| e.to_string())?; + if !res.status().is_success() { + return Err(format!("Consolidate failed with status {}", res.status())); + } + Ok(()) + } + + /// Asynchronously drops/clears the memory store in `agentmemory`. + pub async fn drop_memories(&self) -> Result<(), String> { + let url = format!("{}/agentmemory/forget", self.api_base()); + let client = reqwest::Client::new(); + let res = client.post(&url).json(&json!({"all": true})).send().await.map_err(|e| e.to_string())?; + if !res.status().is_success() { + return Err(format!("Forget failed with status {}", res.status())); + } + Ok(()) + } +} \ No newline at end of file diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 9c0fdacfa..e6702d68c 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -6008,7 +6008,7 @@ pub(crate) async fn run_turn( let adapter = crate::agentmemory::AgentmemoryAdapter::new(); let payload = stop_request.clone(); tokio::spawn(async move { - adapter.capture_event("Stop", payload).await; + adapter.capture_event("Stop", serde_json::to_value(&payload).unwrap_or_default()).await; }); } diff --git a/codex-rs/core/src/hook_runtime.rs b/codex-rs/core/src/hook_runtime.rs index b8832b1a7..727b59ed3 100644 --- a/codex-rs/core/src/hook_runtime.rs +++ b/codex-rs/core/src/hook_runtime.rs @@ -106,7 +106,7 @@ pub(crate) async fn run_pending_session_start_hooks( let adapter = crate::agentmemory::AgentmemoryAdapter::new(); let payload = request.clone(); tokio::spawn(async move { - adapter.capture_event("SessionStart", payload).await; + adapter.capture_event("SessionStart", serde_json::to_value(&payload).unwrap_or_default()).await; }); } @@ -146,7 +146,7 @@ pub(crate) async fn run_pre_tool_use_hooks( let adapter = crate::agentmemory::AgentmemoryAdapter::new(); let payload = request.clone(); tokio::spawn(async move { - adapter.capture_event("PreToolUse", payload).await; + adapter.capture_event("PreToolUse", serde_json::to_value(&payload).unwrap_or_default()).await; }); } @@ -188,7 +188,7 @@ pub(crate) async fn run_post_tool_use_hooks( let adapter = crate::agentmemory::AgentmemoryAdapter::new(); let payload = request.clone(); tokio::spawn(async move { - adapter.capture_event("PostToolUse", payload).await; + adapter.capture_event("PostToolUse", serde_json::to_value(&payload).unwrap_or_default()).await; }); } @@ -224,7 +224,7 @@ pub(crate) async fn run_post_tool_use_failure_hooks( let adapter = crate::agentmemory::AgentmemoryAdapter::new(); let payload = request.clone(); tokio::spawn(async move { - adapter.capture_event("PostToolUseFailure", payload).await; + adapter.capture_event("PostToolUseFailure", serde_json::to_value(&payload).unwrap_or_default()).await; }); } } @@ -248,7 +248,7 @@ pub(crate) async fn run_user_prompt_submit_hooks( let adapter = crate::agentmemory::AgentmemoryAdapter::new(); let payload = request.clone(); tokio::spawn(async move { - adapter.capture_event("UserPromptSubmit", payload).await; + adapter.capture_event("UserPromptSubmit", serde_json::to_value(&payload).unwrap_or_default()).await; }); } diff --git a/codex-rs/hooks/src/events/post_tool_use.rs b/codex-rs/hooks/src/events/post_tool_use.rs index 3af9bef5e..13c365b7d 100644 --- a/codex-rs/hooks/src/events/post_tool_use.rs +++ b/codex-rs/hooks/src/events/post_tool_use.rs @@ -18,7 +18,7 @@ use crate::engine::output_parser; use crate::schema::PostToolUseCommandInput; use crate::schema::PostToolUseToolInput; -#[derive(Debug, Clone)] +#[derive(Debug, Clone, serde::Serialize)] pub struct PostToolUseRequest { pub session_id: ThreadId, pub turn_id: String, diff --git a/codex-rs/hooks/src/events/pre_tool_use.rs b/codex-rs/hooks/src/events/pre_tool_use.rs index 8366bb632..7329f2a1b 100644 --- a/codex-rs/hooks/src/events/pre_tool_use.rs +++ b/codex-rs/hooks/src/events/pre_tool_use.rs @@ -16,7 +16,7 @@ use crate::engine::dispatcher; use crate::engine::output_parser; use crate::schema::PreToolUseCommandInput; -#[derive(Debug, Clone)] +#[derive(Debug, Clone, serde::Serialize)] pub struct PreToolUseRequest { pub session_id: ThreadId, pub turn_id: String, diff --git a/codex-rs/hooks/src/events/session_start.rs b/codex-rs/hooks/src/events/session_start.rs index 6b8fcad1e..d2a025cba 100644 --- a/codex-rs/hooks/src/events/session_start.rs +++ b/codex-rs/hooks/src/events/session_start.rs @@ -16,7 +16,7 @@ use crate::engine::dispatcher; use crate::engine::output_parser; use crate::schema::SessionStartCommandInput; -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, serde::Serialize)] pub enum SessionStartSource { Startup, Resume, @@ -31,7 +31,7 @@ impl SessionStartSource { } } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, serde::Serialize)] pub struct SessionStartRequest { pub session_id: ThreadId, pub cwd: PathBuf, diff --git a/codex-rs/hooks/src/events/stop.rs b/codex-rs/hooks/src/events/stop.rs index 3d94e321c..ff12ad402 100644 --- a/codex-rs/hooks/src/events/stop.rs +++ b/codex-rs/hooks/src/events/stop.rs @@ -18,7 +18,7 @@ use crate::engine::output_parser; use crate::schema::NullableString; use crate::schema::StopCommandInput; -#[derive(Debug, Clone)] +#[derive(Debug, Clone, serde::Serialize)] pub struct StopRequest { pub session_id: ThreadId, pub turn_id: String, diff --git a/codex-rs/hooks/src/events/user_prompt_submit.rs b/codex-rs/hooks/src/events/user_prompt_submit.rs index b909c183b..f4a4b5090 100644 --- a/codex-rs/hooks/src/events/user_prompt_submit.rs +++ b/codex-rs/hooks/src/events/user_prompt_submit.rs @@ -17,7 +17,7 @@ use crate::engine::output_parser; use crate::schema::NullableString; use crate::schema::UserPromptSubmitCommandInput; -#[derive(Debug, Clone)] +#[derive(Debug, Clone, serde::Serialize)] pub struct UserPromptSubmitRequest { pub session_id: ThreadId, pub turn_id: String, diff --git a/patch_agentmemory.diff b/patch_agentmemory.diff new file mode 100644 index 000000000..dd9a699fb --- /dev/null +++ b/patch_agentmemory.diff @@ -0,0 +1,99 @@ +--- codex-rs/core/src/agentmemory/mod.rs ++++ codex-rs/core/src/agentmemory/mod.rs +@@ -4,6 +4,7 @@ + //! as a replacement for Codex's native memory engine. + + use std::path::Path; ++use std::sync::OnceLock; + use serde_json::json; + + /// A placeholder adapter struct for agentmemory integration. +@@ -11,6 +12,16 @@ + pub struct AgentmemoryAdapter { + // Configuration and state will be added here in subsequent PRs. + } ++ ++/// A shared, pooled HTTP client for agentmemory interactions. ++/// Reusing the client allows connection pooling (keep-alive) for high throughput. ++static CLIENT: OnceLock = OnceLock::new(); ++ ++fn get_client() -> &'static reqwest::Client { ++ CLIENT.get_or_init(|| { ++ reqwest::Client::builder().build().unwrap_or_default() ++ }) ++} + + impl AgentmemoryAdapter { + pub fn new() -> Self { +@@ -32,7 +43,7 @@ + _codex_home: &Path, + _token_budget: usize, + ) -> Option { +- let client = reqwest::Client::new(); ++ let client = get_client(); + let url = format!("{}/agentmemory/profile", self.api_base()); + let profile_result = client.get(&url).send().await; + +@@ -53,35 +64,54 @@ + Some(instructions) + } + ++ /// Transforms Codex's internal hook payloads into Claude-parity structures ++ /// expected by the `agentmemory` REST API. This provides a central, malleable ++ /// place to adjust mapping logic in the future without touching the hooks engine. ++ fn format_claude_parity_payload(&self, event_name: &str, mut payload: serde_json::Value) -> serde_json::Value { ++ // TODO: As agentmemory evolves, perform explicit property mapping here. ++ // For example, mapping Codex `turn_id` to Claude `message_id` or extracting specific nested fields. ++ ++ json!({ ++ "event": event_name, ++ "payload": payload, ++ }) ++ } ++ + /// Asynchronously captures and stores lifecycle events in `agentmemory`. + /// + /// This method allows Codex hooks (like `SessionStart`, `PostToolUse`) to + /// be transmitted without blocking the hot path of the shell or model output. + pub async fn capture_event(&self, event_name: &str, payload_json: serde_json::Value) { + let url = format!("{}/agentmemory/observe", self.api_base()); +- let client = reqwest::Client::new(); +- let body = json!({ +- "event": event_name, +- "payload": payload_json, +- }); +- let _ = client.post(&url).json(&body).send().await; ++ let client = get_client(); ++ ++ let body = self.format_claude_parity_payload(event_name, payload_json); ++ ++ if let Err(e) = client.post(&url).json(&body).send().await { ++ // Log a warning instead of failing silently. This won't crash the session, ++ // but will alert developers that memory observation is degraded. ++ tracing::warn!( ++ "Agentmemory observation failed: could not send {} event to {}: {}", ++ event_name, url, e ++ ); ++ } + } + + /// Asynchronously triggers a memory refresh/update operation in `agentmemory`. + pub async fn update_memories(&self) -> Result<(), String> { + let url = format!("{}/agentmemory/consolidate", self.api_base()); +- let client = reqwest::Client::new(); ++ let client = get_client(); + let res = client.post(&url).send().await.map_err(|e| e.to_string())?; + if !res.status().is_success() { + return Err(format!("Consolidate failed with status {}", res.status())); + } + Ok(()) + } + + /// Asynchronously drops/clears the memory store in `agentmemory`. + pub async fn drop_memories(&self) -> Result<(), String> { + let url = format!("{}/agentmemory/forget", self.api_base()); +- let client = reqwest::Client::new(); ++ let client = get_client(); + let res = client.post(&url).json(&json!({"all": true})).send().await.map_err(|e| e.to_string())?; + if !res.status().is_success() { + return Err(format!("Forget failed with status {}", res.status())); From 78281454bd07b5ee8ca742ce58fc765e5d4f71d0 Mon Sep 17 00:00:00 2001 From: Eric Juta Date: Thu, 26 Mar 2026 22:10:04 +0000 Subject: [PATCH 16/45] test: add agentmemory adapter unit tests --- codex-rs/core/src/agentmemory/mod.rs | 67 +++++++++++++++++----- codex-rs/core/src/agentmemory/mod.rs.patch | 27 +++++++++ codex-rs/core/src/hook_runtime.rs | 3 +- codex-rs/core/src/tools/registry.rs | 1 + patch_tests.diff | 43 ++++++++++++++ 5 files changed, 125 insertions(+), 16 deletions(-) create mode 100644 codex-rs/core/src/agentmemory/mod.rs.patch create mode 100644 patch_tests.diff diff --git a/codex-rs/core/src/agentmemory/mod.rs b/codex-rs/core/src/agentmemory/mod.rs index 7cc1c7da6..0f594d7f5 100644 --- a/codex-rs/core/src/agentmemory/mod.rs +++ b/codex-rs/core/src/agentmemory/mod.rs @@ -41,22 +41,34 @@ impl AgentmemoryAdapter { /// uses hybrid search semantics rather than loading large static artifacts. pub async fn build_startup_developer_instructions( &self, - _codex_home: &Path, - _token_budget: usize, + codex_home: &Path, + token_budget: usize, ) -> Option { let client = get_client(); - let url = format!("{}/agentmemory/profile", self.api_base()); - let profile_result = client.get(&url).send().await; + let url = format!("{}/agentmemory/context", self.api_base()); + let project = std::env::current_dir() + .unwrap_or_else(|_| codex_home.to_path_buf()) + .to_string_lossy() + .into_owned(); + + let request_body = json!({ + "sessionId": "startup", // We don't have a session ID at this exact moment easily accessible, but "startup" excludes it safely. + "project": project, + "budget": token_budget + }); + + let context_result = client.post(&url).json(&request_body).send().await; let mut instructions = "Use the `AgentMemory` tools to search and retrieve relevant memory.\n\ Your context is bounded; use targeted queries to expand details as needed.".to_string(); - if let Ok(res) = profile_result { - if let Ok(text) = res.text().await { - if !text.is_empty() { - instructions.push_str("\n\n\n"); - instructions.push_str(&text); - instructions.push_str("\n"); + if let Ok(res) = context_result { + if let Ok(json_res) = res.json::().await { + if let Some(context_str) = json_res.get("context").and_then(|v| v.as_str()) { + if !context_str.is_empty() { + instructions.push_str("\n\n"); + instructions.push_str(context_str); + } } } } @@ -68,12 +80,15 @@ impl AgentmemoryAdapter { /// expected by the `agentmemory` REST API. This provides a central, malleable /// place to adjust mapping logic in the future without touching the hooks engine. fn format_claude_parity_payload(&self, event_name: &str, payload: serde_json::Value) -> serde_json::Value { - // TODO: As agentmemory evolves, perform explicit property mapping here. - // For example, mapping Codex `turn_id` to Claude `message_id` or extracting specific nested fields. + let session_id = payload.get("session_id").and_then(|v| v.as_str()).unwrap_or("unknown").to_string(); + + let timestamp = chrono::Utc::now().to_rfc3339(); json!({ - "event": event_name, - "payload": payload, + "sessionId": session_id, + "hookType": event_name, + "timestamp": timestamp, + "data": payload, }) } @@ -118,4 +133,26 @@ impl AgentmemoryAdapter { } Ok(()) } -} \ No newline at end of file +} +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_format_claude_parity_payload() { + let adapter = AgentmemoryAdapter::new(); + let raw_payload = json!({ + "session_id": "1234", + "turn_id": "turn-5", + "command": "echo hello" + }); + + let formatted = adapter.format_claude_parity_payload("PreToolUse", raw_payload.clone()); + + assert_eq!(formatted["sessionId"], "1234"); + assert_eq!(formatted["hookType"], "PreToolUse"); + assert!(formatted.get("timestamp").is_some()); + assert_eq!(formatted["data"], raw_payload); + } +} diff --git a/codex-rs/core/src/agentmemory/mod.rs.patch b/codex-rs/core/src/agentmemory/mod.rs.patch new file mode 100644 index 000000000..bec703782 --- /dev/null +++ b/codex-rs/core/src/agentmemory/mod.rs.patch @@ -0,0 +1,27 @@ +--- codex-rs/core/src/agentmemory/mod.rs ++++ codex-rs/core/src/agentmemory/mod.rs +@@ -118,22 +118,21 @@ + Ok(()) + } + } + + #[cfg(test)] + mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_format_claude_parity_payload() { + let adapter = AgentmemoryAdapter::new(); + let raw_payload = json!({ + "session_id": "1234", + "turn_id": "turn-5", + "command": "echo hello" + }); + + let formatted = adapter.format_claude_parity_payload("PreToolUse", raw_payload.clone()); + + assert_eq!(formatted["event"], "PreToolUse"); + assert_eq!(formatted["payload"], raw_payload); + } + } diff --git a/codex-rs/core/src/hook_runtime.rs b/codex-rs/core/src/hook_runtime.rs index 727b59ed3..d4d0d46b5 100644 --- a/codex-rs/core/src/hook_runtime.rs +++ b/codex-rs/core/src/hook_runtime.rs @@ -206,6 +206,7 @@ pub(crate) async fn run_post_tool_use_failure_hooks( tool_name: String, tool_use_id: String, command: String, + error_message: String, ) { let request = PostToolUseRequest { session_id: sess.conversation_id, @@ -217,7 +218,7 @@ pub(crate) async fn run_post_tool_use_failure_hooks( tool_name, tool_use_id, command, - tool_response: serde_json::json!({ "error": "tool failed" }), + tool_response: serde_json::json!({ "error": error_message }), }; if turn_context.config.memories.backend == crate::config::types::MemoryBackend::Agentmemory { diff --git a/codex-rs/core/src/tools/registry.rs b/codex-rs/core/src/tools/registry.rs index 128d5f7d5..0c525fb24 100644 --- a/codex-rs/core/src/tools/registry.rs +++ b/codex-rs/core/src/tools/registry.rs @@ -392,6 +392,7 @@ impl ToolRegistry { payload.tool_name.clone(), invocation.call_id.clone(), payload.command.clone(), + output_preview.clone(), ) .await; } diff --git a/patch_tests.diff b/patch_tests.diff new file mode 100644 index 000000000..6ba86a5d7 --- /dev/null +++ b/patch_tests.diff @@ -0,0 +1,43 @@ +--- codex-rs/core/src/agentmemory/mod.rs ++++ codex-rs/core/src/agentmemory/mod.rs +@@ -118,3 +118,48 @@ + Ok(()) + } + } ++ ++#[cfg(test)] ++mod tests { ++ use super::*; ++ use serde_json::json; ++ ++ #[test] ++ fn test_api_base_default() { ++ // Ensure env var is not set ++ std::env::remove_var("III_REST_PORT"); ++ let adapter = AgentmemoryAdapter::new(); ++ assert_eq!(adapter.api_base(), "http://localhost:3111"); ++ } ++ ++ #[test] ++ fn test_api_base_custom_port() { ++ std::env::set_var("III_REST_PORT", "4000"); ++ let adapter = AgentmemoryAdapter::new(); ++ assert_eq!(adapter.api_base(), "http://localhost:4000"); ++ std::env::remove_var("III_REST_PORT"); ++ } ++ ++ #[test] ++ fn test_format_claude_parity_payload() { ++ let adapter = AgentmemoryAdapter::new(); ++ let raw_payload = json!({ ++ "session_id": "1234", ++ "turn_id": "turn-5", ++ "command": "echo hello" ++ }); ++ ++ let formatted = adapter.format_claude_parity_payload("PreToolUse", raw_payload.clone()); ++ ++ assert_eq!(formatted["event"], "PreToolUse"); ++ assert_eq!(formatted["payload"], raw_payload); ++ } ++} From 2cc63f9130720dff96dfcb5474fcc922bbd2999c Mon Sep 17 00:00:00 2001 From: Eric Juta Date: Fri, 27 Mar 2026 00:22:56 +0000 Subject: [PATCH 17/45] docs: mark agentmemory replacement spec as implemented --- docs/agentmemory-codex-memory-replacement-spec.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/agentmemory-codex-memory-replacement-spec.md b/docs/agentmemory-codex-memory-replacement-spec.md index 451fa4a4c..e9c8c5e64 100644 --- a/docs/agentmemory-codex-memory-replacement-spec.md +++ b/docs/agentmemory-codex-memory-replacement-spec.md @@ -1,5 +1,8 @@ # agentmemory replacement spec for Codex native memory +**Status: Implemented** +The integration of `agentmemory` has been completed across the Codex codebase. The native memory backend has been bypassed when the `agentmemory` backend is configured, and all lifecycle events, high-signal tool payloads, and memory operations (Update, Drop) have been routed to the `agentmemory` adapter. + This document evaluates whether a forked Codex should disable the current first-party memory system and replace it with `~/Projects/agentmemory` as the primary memory engine. From 43bb23a7a40c595aa60679dccb3ce34f9b362d0d Mon Sep 17 00:00:00 2001 From: Eric Juta Date: Fri, 27 Mar 2026 00:25:02 +0000 Subject: [PATCH 18/45] fix: rename memory slash commands and make them visible builtins --- codex-rs/tui/src/slash_command.rs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index ec624d3fb..5892f3443 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -59,10 +59,10 @@ pub enum SlashCommand { TestApproval, #[strum(serialize = "subagents")] MultiAgents, - // Debugging commands. - #[strum(serialize = "debug-m-drop")] + // Memory commands. + #[strum(serialize = "memory-drop")] MemoryDrop, - #[strum(serialize = "debug-m-update")] + #[strum(serialize = "memory-update")] MemoryUpdate, } @@ -92,8 +92,8 @@ impl SlashCommand { SlashCommand::Theme => "choose a syntax highlighting theme", SlashCommand::Ps => "list background terminals", SlashCommand::Stop => "stop all background terminals", - SlashCommand::MemoryDrop => "DO NOT USE", - SlashCommand::MemoryUpdate => "DO NOT USE", + SlashCommand::MemoryDrop => "clear the active memory store", + SlashCommand::MemoryUpdate => "sync and consolidate memories", SlashCommand::Model => "choose what model and reasoning effort to use", SlashCommand::Fast => "toggle Fast mode to enable fastest inference at 2X plan usage", SlashCommand::Personality => "choose a communication style for Codex", @@ -156,9 +156,7 @@ impl SlashCommand { | SlashCommand::Review | SlashCommand::Plan | SlashCommand::Clear - | SlashCommand::Logout - | SlashCommand::MemoryDrop - | SlashCommand::MemoryUpdate => false, + | SlashCommand::Logout => false, SlashCommand::Diff | SlashCommand::Copy | SlashCommand::Rename @@ -168,6 +166,8 @@ impl SlashCommand { | SlashCommand::DebugConfig | SlashCommand::Ps | SlashCommand::Stop + | SlashCommand::MemoryDrop + | SlashCommand::MemoryUpdate | SlashCommand::Mcp | SlashCommand::Apps | SlashCommand::Plugins From 0d47d05e56807deccbea781bf43cf4c442b8eeac Mon Sep 17 00:00:00 2001 From: Eric Juta Date: Fri, 27 Mar 2026 00:26:04 +0000 Subject: [PATCH 19/45] fix: correctly show memory slash commands when agentmemory is enabled --- codex-rs/tui/src/bottom_pane/slash_commands.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/tui/src/bottom_pane/slash_commands.rs b/codex-rs/tui/src/bottom_pane/slash_commands.rs index 7d475108e..0c14e57f4 100644 --- a/codex-rs/tui/src/bottom_pane/slash_commands.rs +++ b/codex-rs/tui/src/bottom_pane/slash_commands.rs @@ -38,7 +38,7 @@ pub(crate) fn builtins_for_input(flags: BuiltinCommandFlags) -> Vec<(&'static st .filter(|(_, cmd)| flags.personality_command_enabled || *cmd != SlashCommand::Personality) .filter(|(_, cmd)| flags.realtime_conversation_enabled || *cmd != SlashCommand::Realtime) .filter(|(_, cmd)| flags.audio_device_selection_enabled || *cmd != SlashCommand::Settings) - .filter(|(_, cmd)| !flags.agentmemory_enabled || !matches!(*cmd, SlashCommand::MemoryDrop | SlashCommand::MemoryUpdate)) + .filter(|(_, cmd)| flags.agentmemory_enabled || !matches!(*cmd, SlashCommand::MemoryDrop | SlashCommand::MemoryUpdate)) .collect() } From ee99969de6a7c22bc53a7ede86ebdcbf03408a7e Mon Sep 17 00:00:00 2001 From: Eric Juta Date: Fri, 27 Mar 2026 19:16:20 +0000 Subject: [PATCH 20/45] Improve Agentmemory session payloads Align Agentmemory hook payloads with the canonical schema, register sessions for viewer visibility, and harden missing custom tool outputs so interrupted multi-agent tool calls degrade to synthetic aborted outputs instead of panicking. Add a follow-up spec for remaining payload-quality work. Co-authored-by: Codex --- codex-rs/core/src/agentmemory/mod.rs | 310 ++++++++++++++++-- codex-rs/core/src/codex.rs | 20 +- .../core/src/context_manager/history_tests.rs | 32 ++ .../core/src/context_manager/normalize.rs | 4 +- codex-rs/core/src/hook_runtime.rs | 43 ++- .../docs/agentmemory_payload_quality_spec.md | 201 ++++++++++++ 6 files changed, 569 insertions(+), 41 deletions(-) create mode 100644 codex-rs/docs/agentmemory_payload_quality_spec.md diff --git a/codex-rs/core/src/agentmemory/mod.rs b/codex-rs/core/src/agentmemory/mod.rs index 0f594d7f5..6aee195ee 100644 --- a/codex-rs/core/src/agentmemory/mod.rs +++ b/codex-rs/core/src/agentmemory/mod.rs @@ -3,9 +3,9 @@ //! This module provides the seam for integrating the `agentmemory` service //! as a replacement for Codex's native memory engine. +use serde_json::json; use std::path::Path; use std::sync::OnceLock; -use serde_json::json; /// A placeholder adapter struct for agentmemory integration. #[derive(Debug, Default, Clone)] @@ -18,9 +18,7 @@ pub struct AgentmemoryAdapter { static CLIENT: OnceLock = OnceLock::new(); fn get_client() -> &'static reqwest::Client { - CLIENT.get_or_init(|| { - reqwest::Client::builder().build().unwrap_or_default() - }) + CLIENT.get_or_init(|| reqwest::Client::builder().build().unwrap_or_default()) } impl AgentmemoryAdapter { @@ -29,9 +27,16 @@ impl AgentmemoryAdapter { } fn api_base(&self) -> String { + if let Some(url) = std::env::var("AGENTMEMORY_URL") + .ok() + .filter(|url| !url.trim().is_empty()) + { + return url; + } + std::env::var("III_REST_PORT") - .map(|p| format!("http://localhost:{}", p)) - .unwrap_or_else(|_| "http://localhost:3111".to_string()) + .map(|port| format!("http://127.0.0.1:{port}")) + .unwrap_or_else(|_| "http://127.0.0.1:3111".to_string()) } /// Builds the developer instructions for startup memory injection @@ -58,9 +63,11 @@ impl AgentmemoryAdapter { }); let context_result = client.post(&url).json(&request_body).send().await; - - let mut instructions = "Use the `AgentMemory` tools to search and retrieve relevant memory.\n\ - Your context is bounded; use targeted queries to expand details as needed.".to_string(); + + let mut instructions = + "Use the `AgentMemory` tools to search and retrieve relevant memory.\n\ + Your context is bounded; use targeted queries to expand details as needed." + .to_string(); if let Ok(res) = context_result { if let Ok(json_res) = res.json::().await { @@ -72,23 +79,128 @@ impl AgentmemoryAdapter { } } } - + Some(instructions) } - /// Transforms Codex's internal hook payloads into Claude-parity structures - /// expected by the `agentmemory` REST API. This provides a central, malleable - /// place to adjust mapping logic in the future without touching the hooks engine. - fn format_claude_parity_payload(&self, event_name: &str, payload: serde_json::Value) -> serde_json::Value { - let session_id = payload.get("session_id").and_then(|v| v.as_str()).unwrap_or("unknown").to_string(); - + /// Transforms Codex hook payloads into the canonical Agentmemory hook schema. + fn format_agentmemory_payload( + &self, + event_name: &str, + payload: serde_json::Value, + ) -> serde_json::Value { + let payload_map = payload.as_object().cloned().unwrap_or_default(); + let session_id = payload_map + .get("session_id") + .and_then(serde_json::Value::as_str) + .unwrap_or("unknown") + .to_string(); + let cwd = payload_map + .get("cwd") + .and_then(serde_json::Value::as_str) + .map(str::to_string) + .unwrap_or_else(|| ".".to_string()); let timestamp = chrono::Utc::now().to_rfc3339(); - + + let tool_input = payload_map + .get("command") + .cloned() + .unwrap_or(serde_json::Value::Null); + let tool_output = payload_map + .get("tool_response") + .cloned() + .unwrap_or(serde_json::Value::Null); + let error = payload_map + .get("tool_response") + .and_then(|value| value.get("error")) + .cloned() + .unwrap_or_else(|| tool_output.clone()); + + let (hook_type, data) = match event_name { + "SessionStart" => ( + "session_start", + json!({ + "session_id": session_id, + "cwd": cwd, + "model": payload_map.get("model").cloned().unwrap_or(serde_json::Value::Null), + "permission_mode": payload_map.get("permission_mode").cloned().unwrap_or(serde_json::Value::Null), + "transcript_path": payload_map.get("transcript_path").cloned().unwrap_or(serde_json::Value::Null), + "source": payload_map.get("source").cloned().unwrap_or(serde_json::Value::Null), + }), + ), + "UserPromptSubmit" => ( + "prompt_submit", + json!({ + "session_id": session_id, + "turn_id": payload_map.get("turn_id").cloned().unwrap_or(serde_json::Value::Null), + "cwd": cwd, + "model": payload_map.get("model").cloned().unwrap_or(serde_json::Value::Null), + "permission_mode": payload_map.get("permission_mode").cloned().unwrap_or(serde_json::Value::Null), + "prompt": payload_map.get("prompt").cloned().unwrap_or(serde_json::Value::Null), + }), + ), + "PreToolUse" => ( + "pre_tool_use", + json!({ + "session_id": session_id, + "turn_id": payload_map.get("turn_id").cloned().unwrap_or(serde_json::Value::Null), + "cwd": cwd, + "model": payload_map.get("model").cloned().unwrap_or(serde_json::Value::Null), + "permission_mode": payload_map.get("permission_mode").cloned().unwrap_or(serde_json::Value::Null), + "tool_name": payload_map.get("tool_name").cloned().unwrap_or(serde_json::Value::Null), + "tool_use_id": payload_map.get("tool_use_id").cloned().unwrap_or(serde_json::Value::Null), + "tool_input": tool_input, + }), + ), + "PostToolUse" => ( + "post_tool_use", + json!({ + "session_id": session_id, + "turn_id": payload_map.get("turn_id").cloned().unwrap_or(serde_json::Value::Null), + "cwd": cwd, + "model": payload_map.get("model").cloned().unwrap_or(serde_json::Value::Null), + "permission_mode": payload_map.get("permission_mode").cloned().unwrap_or(serde_json::Value::Null), + "tool_name": payload_map.get("tool_name").cloned().unwrap_or(serde_json::Value::Null), + "tool_use_id": payload_map.get("tool_use_id").cloned().unwrap_or(serde_json::Value::Null), + "tool_input": tool_input, + "tool_output": tool_output, + }), + ), + "PostToolUseFailure" => ( + "post_tool_failure", + json!({ + "session_id": session_id, + "turn_id": payload_map.get("turn_id").cloned().unwrap_or(serde_json::Value::Null), + "cwd": cwd, + "model": payload_map.get("model").cloned().unwrap_or(serde_json::Value::Null), + "permission_mode": payload_map.get("permission_mode").cloned().unwrap_or(serde_json::Value::Null), + "tool_name": payload_map.get("tool_name").cloned().unwrap_or(serde_json::Value::Null), + "tool_use_id": payload_map.get("tool_use_id").cloned().unwrap_or(serde_json::Value::Null), + "tool_input": tool_input, + "error": error, + }), + ), + "Stop" => ( + "stop", + json!({ + "session_id": session_id, + "turn_id": payload_map.get("turn_id").cloned().unwrap_or(serde_json::Value::Null), + "cwd": cwd, + "model": payload_map.get("model").cloned().unwrap_or(serde_json::Value::Null), + "permission_mode": payload_map.get("permission_mode").cloned().unwrap_or(serde_json::Value::Null), + "last_assistant_message": payload_map.get("last_assistant_message").cloned().unwrap_or(serde_json::Value::Null), + }), + ), + _ => (event_name, serde_json::Value::Object(payload_map.clone())), + }; + json!({ "sessionId": session_id, - "hookType": event_name, + "hookType": hook_type, + "project": cwd, + "cwd": payload_map.get("cwd").cloned().unwrap_or_else(|| serde_json::Value::String(".".to_string())), "timestamp": timestamp, - "data": payload, + "data": data, }) } @@ -99,19 +211,64 @@ impl AgentmemoryAdapter { pub async fn capture_event(&self, event_name: &str, payload_json: serde_json::Value) { let url = format!("{}/agentmemory/observe", self.api_base()); let client = get_client(); - - let body = self.format_claude_parity_payload(event_name, payload_json); - + + let body = self.format_agentmemory_payload(event_name, payload_json); + if let Err(e) = client.post(&url).json(&body).send().await { // Log a warning instead of failing silently. This won't crash the session, // but will alert developers that memory observation is degraded. tracing::warn!( "Agentmemory observation failed: could not send {} event to {}: {}", - event_name, url, e + event_name, + url, + e ); } } + /// Registers a session so Agentmemory's session-backed views can discover it. + pub async fn start_session( + &self, + session_id: &str, + project: &Path, + cwd: &Path, + ) -> Result<(), String> { + let url = format!("{}/agentmemory/session/start", self.api_base()); + let client = get_client(); + let body = json!({ + "sessionId": session_id, + "project": project.display().to_string(), + "cwd": cwd.display().to_string(), + }); + let res = client + .post(&url) + .json(&body) + .send() + .await + .map_err(|e| e.to_string())?; + if !res.status().is_success() { + return Err(format!("Session start failed with status {}", res.status())); + } + Ok(()) + } + + /// Marks a session completed so Agentmemory's viewer can stop showing it as active. + pub async fn end_session(&self, session_id: &str) -> Result<(), String> { + let url = format!("{}/agentmemory/session/end", self.api_base()); + let client = get_client(); + let body = json!({ "sessionId": session_id }); + let res = client + .post(&url) + .json(&body) + .send() + .await + .map_err(|e| e.to_string())?; + if !res.status().is_success() { + return Err(format!("Session end failed with status {}", res.status())); + } + Ok(()) + } + /// Asynchronously triggers a memory refresh/update operation in `agentmemory`. pub async fn update_memories(&self) -> Result<(), String> { let url = format!("{}/agentmemory/consolidate", self.api_base()); @@ -127,7 +284,12 @@ impl AgentmemoryAdapter { pub async fn drop_memories(&self) -> Result<(), String> { let url = format!("{}/agentmemory/forget", self.api_base()); let client = get_client(); - let res = client.post(&url).json(&json!({"all": true})).send().await.map_err(|e| e.to_string())?; + let res = client + .post(&url) + .json(&json!({"all": true})) + .send() + .await + .map_err(|e| e.to_string())?; if !res.status().is_success() { return Err(format!("Forget failed with status {}", res.status())); } @@ -138,21 +300,107 @@ impl AgentmemoryAdapter { mod tests { use super::*; use serde_json::json; + use std::ffi::OsString; + use std::sync::Mutex; + + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + struct EnvVarGuard { + key: &'static str, + original: Option, + } + + impl EnvVarGuard { + fn set(key: &'static str, value: &str) -> Self { + let original = std::env::var_os(key); + unsafe { + std::env::set_var(key, value); + } + Self { key, original } + } + + fn unset(key: &'static str) -> Self { + let original = std::env::var_os(key); + unsafe { + std::env::remove_var(key); + } + Self { key, original } + } + } + + impl Drop for EnvVarGuard { + fn drop(&mut self) { + match self.original.take() { + Some(value) => unsafe { + std::env::set_var(self.key, value); + }, + None => unsafe { + std::env::remove_var(self.key); + }, + } + } + } #[test] - fn test_format_claude_parity_payload() { + fn test_format_agentmemory_payload_maps_prompt_submit_shape() { let adapter = AgentmemoryAdapter::new(); let raw_payload = json!({ "session_id": "1234", "turn_id": "turn-5", - "command": "echo hello" + "cwd": "/tmp/project", + "prompt": "ship it" }); - - let formatted = adapter.format_claude_parity_payload("PreToolUse", raw_payload.clone()); - + + let formatted = adapter.format_agentmemory_payload("UserPromptSubmit", raw_payload); + assert_eq!(formatted["sessionId"], "1234"); - assert_eq!(formatted["hookType"], "PreToolUse"); + assert_eq!(formatted["hookType"], "prompt_submit"); + assert_eq!(formatted["project"], "/tmp/project"); + assert_eq!(formatted["cwd"], "/tmp/project"); assert!(formatted.get("timestamp").is_some()); - assert_eq!(formatted["data"], raw_payload); + assert_eq!(formatted["data"]["prompt"], "ship it"); + assert_eq!(formatted["data"]["turn_id"], "turn-5"); + } + + #[test] + fn test_format_agentmemory_payload_maps_post_tool_use_shape() { + let adapter = AgentmemoryAdapter::new(); + let raw_payload = json!({ + "session_id": "1234", + "turn_id": "turn-5", + "cwd": "/tmp/project", + "tool_name": "shell_command", + "tool_use_id": "tool-1", + "command": "printf hi", + "tool_response": { "output": "hi" } + }); + + let formatted = adapter.format_agentmemory_payload("PostToolUse", raw_payload); + + assert_eq!(formatted["sessionId"], "1234"); + assert_eq!(formatted["hookType"], "post_tool_use"); + assert_eq!(formatted["data"]["tool_name"], "shell_command"); + assert_eq!(formatted["data"]["tool_input"], "printf hi"); + assert_eq!(formatted["data"]["tool_output"]["output"], "hi"); + } + + #[test] + fn test_api_base_prefers_explicit_agentmemory_url() { + let _guard = ENV_LOCK.lock().expect("lock env"); + let adapter = AgentmemoryAdapter::new(); + let _agentmemory_url_guard = EnvVarGuard::set("AGENTMEMORY_URL", "http://127.0.0.1:9999"); + let _rest_port_guard = EnvVarGuard::set("III_REST_PORT", "3111"); + + assert_eq!(adapter.api_base(), "http://127.0.0.1:9999"); + } + + #[test] + fn test_api_base_defaults_to_ipv4_loopback() { + let _guard = ENV_LOCK.lock().expect("lock env"); + let adapter = AgentmemoryAdapter::new(); + let _agentmemory_url_guard = EnvVarGuard::unset("AGENTMEMORY_URL"); + let _rest_port_guard = EnvVarGuard::set("III_REST_PORT", "4242"); + + assert_eq!(adapter.api_base(), "http://127.0.0.1:4242"); } } diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index e6702d68c..847a243d8 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -5343,6 +5343,15 @@ mod handlers { pub async fn shutdown(sess: &Arc, sub_id: String) -> bool { sess.abort_all_tasks(TurnAbortReason::Interrupted).await; + if sess.get_config().await.memories.backend + == crate::config::types::MemoryBackend::Agentmemory + { + let adapter = crate::agentmemory::AgentmemoryAdapter::new(); + let session_id = sess.conversation_id.to_string(); + if let Err(e) = adapter.end_session(session_id.as_str()).await { + warn!("Agentmemory session end failed for {session_id}: {e}"); + } + } let _ = sess.conversation.shutdown().await; sess.services .unified_exec_manager @@ -6004,11 +6013,18 @@ pub(crate) async fn run_turn( last_assistant_message: last_agent_message.clone(), }; - if turn_context.config.memories.backend == crate::config::types::MemoryBackend::Agentmemory { + if turn_context.config.memories.backend + == crate::config::types::MemoryBackend::Agentmemory + { let adapter = crate::agentmemory::AgentmemoryAdapter::new(); let payload = stop_request.clone(); tokio::spawn(async move { - adapter.capture_event("Stop", serde_json::to_value(&payload).unwrap_or_default()).await; + adapter + .capture_event( + "Stop", + serde_json::to_value(&payload).unwrap_or_default(), + ) + .await; }); } diff --git a/codex-rs/core/src/context_manager/history_tests.rs b/codex-rs/core/src/context_manager/history_tests.rs index 3c508e05f..f4ba75f4a 100644 --- a/codex-rs/core/src/context_manager/history_tests.rs +++ b/codex-rs/core/src/context_manager/history_tests.rs @@ -1509,6 +1509,38 @@ fn normalize_adds_missing_output_for_tool_search_call() { ); } +#[test] +fn normalize_adds_missing_output_for_custom_tool_call() { + let items = vec![ResponseItem::CustomToolCall { + id: None, + status: None, + call_id: "custom-call-x".to_string(), + name: "custom_tool".to_string(), + input: "{}".to_string(), + }]; + let mut h = create_history_with_items(items); + + h.normalize_history(&default_input_modalities()); + + assert_eq!( + h.raw_items(), + vec![ + ResponseItem::CustomToolCall { + id: None, + status: None, + call_id: "custom-call-x".to_string(), + name: "custom_tool".to_string(), + input: "{}".to_string(), + }, + ResponseItem::CustomToolCallOutput { + call_id: "custom-call-x".to_string(), + name: None, + output: FunctionCallOutputPayload::from_text("aborted".to_string()), + }, + ] + ); +} + #[cfg(debug_assertions)] #[test] #[should_panic] diff --git a/codex-rs/core/src/context_manager/normalize.rs b/codex-rs/core/src/context_manager/normalize.rs index 839bae331..366244fc4 100644 --- a/codex-rs/core/src/context_manager/normalize.rs +++ b/codex-rs/core/src/context_manager/normalize.rs @@ -72,9 +72,7 @@ pub(crate) fn ensure_call_outputs_present(items: &mut Vec) { }); if !has_output { - error_or_panic(format!( - "Custom tool call output is missing for call id: {call_id}" - )); + info!("Custom tool call output is missing for call id: {call_id}"); missing_outputs_to_insert.push(( idx, ResponseItem::CustomToolCallOutput { diff --git a/codex-rs/core/src/hook_runtime.rs b/codex-rs/core/src/hook_runtime.rs index d4d0d46b5..3c5554bd1 100644 --- a/codex-rs/core/src/hook_runtime.rs +++ b/codex-rs/core/src/hook_runtime.rs @@ -105,8 +105,21 @@ pub(crate) async fn run_pending_session_start_hooks( if turn_context.config.memories.backend == crate::config::types::MemoryBackend::Agentmemory { let adapter = crate::agentmemory::AgentmemoryAdapter::new(); let payload = request.clone(); + let session_id = request.session_id.to_string(); + let cwd = request.cwd.clone(); tokio::spawn(async move { - adapter.capture_event("SessionStart", serde_json::to_value(&payload).unwrap_or_default()).await; + if let Err(e) = adapter + .start_session(session_id.as_str(), cwd.as_path(), cwd.as_path()) + .await + { + tracing::warn!("Agentmemory session start failed for {session_id}: {e}"); + } + adapter + .capture_event( + "SessionStart", + serde_json::to_value(&payload).unwrap_or_default(), + ) + .await; }); } @@ -146,7 +159,12 @@ pub(crate) async fn run_pre_tool_use_hooks( let adapter = crate::agentmemory::AgentmemoryAdapter::new(); let payload = request.clone(); tokio::spawn(async move { - adapter.capture_event("PreToolUse", serde_json::to_value(&payload).unwrap_or_default()).await; + adapter + .capture_event( + "PreToolUse", + serde_json::to_value(&payload).unwrap_or_default(), + ) + .await; }); } @@ -188,7 +206,12 @@ pub(crate) async fn run_post_tool_use_hooks( let adapter = crate::agentmemory::AgentmemoryAdapter::new(); let payload = request.clone(); tokio::spawn(async move { - adapter.capture_event("PostToolUse", serde_json::to_value(&payload).unwrap_or_default()).await; + adapter + .capture_event( + "PostToolUse", + serde_json::to_value(&payload).unwrap_or_default(), + ) + .await; }); } @@ -225,7 +248,12 @@ pub(crate) async fn run_post_tool_use_failure_hooks( let adapter = crate::agentmemory::AgentmemoryAdapter::new(); let payload = request.clone(); tokio::spawn(async move { - adapter.capture_event("PostToolUseFailure", serde_json::to_value(&payload).unwrap_or_default()).await; + adapter + .capture_event( + "PostToolUseFailure", + serde_json::to_value(&payload).unwrap_or_default(), + ) + .await; }); } } @@ -249,7 +277,12 @@ pub(crate) async fn run_user_prompt_submit_hooks( let adapter = crate::agentmemory::AgentmemoryAdapter::new(); let payload = request.clone(); tokio::spawn(async move { - adapter.capture_event("UserPromptSubmit", serde_json::to_value(&payload).unwrap_or_default()).await; + adapter + .capture_event( + "UserPromptSubmit", + serde_json::to_value(&payload).unwrap_or_default(), + ) + .await; }); } diff --git a/codex-rs/docs/agentmemory_payload_quality_spec.md b/codex-rs/docs/agentmemory_payload_quality_spec.md new file mode 100644 index 000000000..1df493844 --- /dev/null +++ b/codex-rs/docs/agentmemory_payload_quality_spec.md @@ -0,0 +1,201 @@ +# Agentmemory Payload Quality Spec + +## Goal + +Improve the usefulness of Agentmemory-derived observations, timelines, and +retrieval context for Codex sessions. + +The current integration is functionally working, but memory quality is still +limited by: + +- lifecycle-heavy noise dominating the observation stream +- incomplete structured tool payloads +- missing assistant-result capture +- weaker file-aware enrichment than the standalone Agentmemory hook scripts + +## Current State + +What is already working: + +- Codex sessions are registered in Agentmemory and appear in the viewer. +- Session lifecycle is closed out on Codex shutdown. +- Hook payloads now use Agentmemory-compatible hook names: + - `session_start` + - `prompt_submit` + - `pre_tool_use` + - `post_tool_use` + - `post_tool_failure` + - `stop` +- Prompt, tool input, tool output, and error fields are mapped into the + canonical Agentmemory schema. + +Remaining gaps: + +- `pre_tool_use` often contains only a shell-style command string instead of + structured tool arguments. +- assistant conclusions/final answers are not emitted as first-class memory + payloads. +- repeated lifecycle hooks create noisy timelines. +- file-enrichment opportunities are weaker than the standalone JavaScript hook + path because Codex does not yet forward structured file arguments in the same + way. + +## Desired Outcomes + +1. Agentmemory timelines should be dominated by user intent, important tool +results, failures, decisions, and conclusions instead of routine lifecycle +markers. +2. Retrieval context should help a later agent answer: + - what the user asked + - what the agent tried + - what succeeded or failed + - what conclusion or decision mattered +3. File-sensitive tasks should yield observations and memories that mention the + relevant paths and search terms when available. + +## Proposed Changes + +### 1. Reduce lifecycle noise + +Default policy: + +- Keep: + - `prompt_submit` + - `post_tool_use` + - `post_tool_failure` + - `session_start` + - `stop` +- Suppress or aggressively gate: + - `pre_tool_use` + +Preferred rule: + +- Do not emit `pre_tool_use` for routine shell or exec traffic by default. +- Only emit `pre_tool_use` when it carries unique high-signal metadata that + will not appear in the corresponding post-tool observation. + +Acceptance criteria: + +- A typical session timeline should contain substantially fewer lifecycle-only + observations. +- Repeated pre-tool lifecycle entries should no longer dominate the top of the + timeline for normal sessions. + +### 2. Preserve structured tool arguments where available + +For tool-use events, prefer structured payloads over flattened command strings +when the source event includes them. + +Examples of desired fields: + +- file-oriented tools: + - `file_path` + - `path` + - `paths` + - `pattern` +- search-oriented tools: + - `query` + - `pattern` + - `glob` +- execution tools: + - structured command arguments when available + +If both structured fields and a command string exist: + +- preserve the structured fields in `tool_input` +- optionally keep the command string under a separate field if it adds value + +Acceptance criteria: + +- Agentmemory compressed observations for file/search/edit tasks should more + often include exact file paths and more task-specific titles. + +### 3. Capture assistant result / conclusion payloads + +Add a new observation path for assistant-visible conclusions, not only tool +activity. + +Possible event classes: + +- final assistant message at turn completion +- synthesized task result / conclusion +- important stop-summary payload when the agent has a meaningful last answer + +Minimum useful fields: + +- assistant text, truncated to a safe size +- turn id +- session id +- cwd +- optional tags for whether the text is final, partial, or summary content + +Acceptance criteria: + +- Sessions with little or no tool usage still produce useful memory records. +- Retrieval can surface what the agent concluded, not just what tools ran. + +### 4. Improve file-aware enrichment parity + +Bring the Rust integration closer to the standalone Agentmemory JavaScript hook +behavior for file-aware tools. + +When structured file/search arguments are available, enable the same sort of +file-context enrichment that the JavaScript `pre-tool-use` hook performs. + +Acceptance criteria: + +- Memory observations for file edits/searches are more likely to mention the + touched paths and relevant search terms. + +### 5. Add quality evaluation fixtures + +Create a small regression corpus of real Codex sessions and evaluate: + +- timeline readability +- compressed observation usefulness +- retrieval usefulness for follow-up questions + +Suggested evaluation checks: + +1. Timeline signal ratio + - proportion of useful task observations vs lifecycle-only observations +2. Retrieval usefulness + - given a follow-up question, does returned context contain the task, action, + result, and conclusion? +3. File recall quality + - for file-sensitive sessions, do observations and retrieval mention the + correct paths? + +Acceptance criteria: + +- At least one representative multi-tool session becomes obviously more useful + to inspect in the viewer after the changes. +- Retrieval answers improve on a fixed before/after comparison for the same + session set. + +## Non-Goals + +- perfect semantic summarization of every session +- preserving every lifecycle marker in the durable memory stream +- storing unbounded tool outputs +- introducing heavy blocking calls on the hot path of Codex tool execution + +## Rollout Order + +1. suppress or gate low-value lifecycle observations +2. forward richer structured tool input +3. add assistant-result capture +4. add evaluation fixtures and compare before/after quality + +## Risks + +- over-filtering may remove useful debugging evidence +- assistant-result capture may duplicate information already present in tool + outputs if not scoped carefully +- richer structured payloads may increase observation size and compression cost + +## Mitigations + +- keep raw observation size limits and truncation +- prefer targeted gating over blanket event deletion +- evaluate with real-session fixtures before expanding the payload surface From 01e9eefbc8776673ed91a534f03fc2690b25e187 Mon Sep 17 00:00:00 2001 From: Eric Juta Date: Fri, 27 Mar 2026 21:08:46 +0000 Subject: [PATCH 21/45] Implement agentmemory payload quality spec Enrich agentmemory observations with structured tool arguments, file-aware enrichment, and assistant-result capture so retrieval context surfaces what the user asked, what tools touched, and what the agent concluded. - Parse JSON command strings into structured tool_input objects - Extract file paths and search terms into top-level `files`/`search_terms` fields on pre_tool_use, post_tool_use, and post_tool_failure events - Emit new `assistant_result` observation at turn completion with truncated assistant text, turn_id, session_id, cwd, and model - Add 12 unit tests covering structured parsing, file enrichment, assistant result shape, and truncation - Mark spec rollout items as implemented Co-Authored-By: Claude Opus 4.6 (1M context) --- codex-rs/core/src/agentmemory/mod.rs | 308 +++++++++++++++++- codex-rs/core/src/codex.rs | 36 +- .../docs/agentmemory_payload_quality_spec.md | 8 +- 3 files changed, 327 insertions(+), 25 deletions(-) diff --git a/codex-rs/core/src/agentmemory/mod.rs b/codex-rs/core/src/agentmemory/mod.rs index 6aee195ee..11391fc09 100644 --- a/codex-rs/core/src/agentmemory/mod.rs +++ b/codex-rs/core/src/agentmemory/mod.rs @@ -83,6 +83,75 @@ impl AgentmemoryAdapter { Some(instructions) } + /// Attempts to parse a tool command string as JSON to recover structured + /// arguments. Falls back to the original string value on parse failure. + fn parse_structured_tool_input(raw: &serde_json::Value) -> serde_json::Value { + if let Some(s) = raw.as_str() { + if let Ok(parsed) = serde_json::from_str::(s) { + if parsed.is_object() { + return parsed; + } + } + } + raw.clone() + } + + /// Extracts file paths and search terms from structured tool arguments + /// so that Agentmemory observations mention the relevant paths and queries. + fn extract_file_enrichment( + tool_input: &serde_json::Value, + ) -> (Vec, Vec) { + let mut files: Vec = Vec::new(); + let mut search_terms: Vec = Vec::new(); + + if let Some(obj) = tool_input.as_object() { + // File path fields + for key in &["file_path", "path", "dir_path"] { + if let Some(v) = obj.get(*key).and_then(|v| v.as_str()) { + if !v.is_empty() { + files.push(v.to_string()); + } + } + } + // Array of paths + if let Some(arr) = obj.get("paths").and_then(|v| v.as_array()) { + for item in arr { + if let Some(s) = item.as_str() { + if !s.is_empty() { + files.push(s.to_string()); + } + } + } + } + // Search / pattern fields + for key in &["query", "pattern", "glob"] { + if let Some(v) = obj.get(*key).and_then(|v| v.as_str()) { + if !v.is_empty() { + search_terms.push(v.to_string()); + } + } + } + } + + (files, search_terms) + } + + /// Maximum length for assistant text stored in observations. + const ASSISTANT_TEXT_MAX_BYTES: usize = 4096; + + /// Truncates text to a safe size for observation storage. + fn truncate_text(text: &str, max_bytes: usize) -> &str { + if text.len() <= max_bytes { + return text; + } + // Find a char boundary at or before max_bytes + let mut end = max_bytes; + while end > 0 && !text.is_char_boundary(end) { + end -= 1; + } + &text[..end] + } + /// Transforms Codex hook payloads into the canonical Agentmemory hook schema. fn format_agentmemory_payload( &self, @@ -102,10 +171,13 @@ impl AgentmemoryAdapter { .unwrap_or_else(|| ".".to_string()); let timestamp = chrono::Utc::now().to_rfc3339(); - let tool_input = payload_map + // Parse structured tool input from the command string when possible. + let raw_tool_input = payload_map .get("command") .cloned() .unwrap_or(serde_json::Value::Null); + let tool_input = Self::parse_structured_tool_input(&raw_tool_input); + let tool_output = payload_map .get("tool_response") .cloned() @@ -116,6 +188,9 @@ impl AgentmemoryAdapter { .cloned() .unwrap_or_else(|| tool_output.clone()); + // Extract file paths and search terms for enrichment. + let (files, search_terms) = Self::extract_file_enrichment(&tool_input); + let (hook_type, data) = match event_name { "SessionStart" => ( "session_start", @@ -139,9 +214,8 @@ impl AgentmemoryAdapter { "prompt": payload_map.get("prompt").cloned().unwrap_or(serde_json::Value::Null), }), ), - "PreToolUse" => ( - "pre_tool_use", - json!({ + "PreToolUse" => { + let mut data = json!({ "session_id": session_id, "turn_id": payload_map.get("turn_id").cloned().unwrap_or(serde_json::Value::Null), "cwd": cwd, @@ -150,11 +224,17 @@ impl AgentmemoryAdapter { "tool_name": payload_map.get("tool_name").cloned().unwrap_or(serde_json::Value::Null), "tool_use_id": payload_map.get("tool_use_id").cloned().unwrap_or(serde_json::Value::Null), "tool_input": tool_input, - }), - ), - "PostToolUse" => ( - "post_tool_use", - json!({ + }); + if !files.is_empty() { + data["files"] = json!(files); + } + if !search_terms.is_empty() { + data["search_terms"] = json!(search_terms); + } + ("pre_tool_use", data) + } + "PostToolUse" => { + let mut data = json!({ "session_id": session_id, "turn_id": payload_map.get("turn_id").cloned().unwrap_or(serde_json::Value::Null), "cwd": cwd, @@ -164,11 +244,18 @@ impl AgentmemoryAdapter { "tool_use_id": payload_map.get("tool_use_id").cloned().unwrap_or(serde_json::Value::Null), "tool_input": tool_input, "tool_output": tool_output, - }), - ), - "PostToolUseFailure" => ( - "post_tool_failure", - json!({ + }); + // File-aware enrichment: surface paths and search terms. + if !files.is_empty() { + data["files"] = json!(files); + } + if !search_terms.is_empty() { + data["search_terms"] = json!(search_terms); + } + ("post_tool_use", data) + } + "PostToolUseFailure" => { + let mut data = json!({ "session_id": session_id, "turn_id": payload_map.get("turn_id").cloned().unwrap_or(serde_json::Value::Null), "cwd": cwd, @@ -178,8 +265,15 @@ impl AgentmemoryAdapter { "tool_use_id": payload_map.get("tool_use_id").cloned().unwrap_or(serde_json::Value::Null), "tool_input": tool_input, "error": error, - }), - ), + }); + if !files.is_empty() { + data["files"] = json!(files); + } + if !search_terms.is_empty() { + data["search_terms"] = json!(search_terms); + } + ("post_tool_failure", data) + } "Stop" => ( "stop", json!({ @@ -191,6 +285,25 @@ impl AgentmemoryAdapter { "last_assistant_message": payload_map.get("last_assistant_message").cloned().unwrap_or(serde_json::Value::Null), }), ), + "AssistantResult" => { + let assistant_text = payload_map + .get("assistant_text") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let truncated = + Self::truncate_text(assistant_text, Self::ASSISTANT_TEXT_MAX_BYTES); + ( + "assistant_result", + json!({ + "session_id": session_id, + "turn_id": payload_map.get("turn_id").cloned().unwrap_or(serde_json::Value::Null), + "cwd": cwd, + "model": payload_map.get("model").cloned().unwrap_or(serde_json::Value::Null), + "assistant_text": truncated, + "is_final": payload_map.get("is_final").cloned().unwrap_or(json!(true)), + }), + ) + } _ => (event_name, serde_json::Value::Object(payload_map.clone())), }; @@ -384,6 +497,169 @@ mod tests { assert_eq!(formatted["data"]["tool_output"]["output"], "hi"); } + #[test] + fn test_pre_tool_use_includes_structured_args_and_enrichment() { + let adapter = AgentmemoryAdapter::new(); + let raw_payload = json!({ + "session_id": "s1", + "turn_id": "t1", + "cwd": "/proj", + "tool_name": "grep", + "tool_use_id": "tu-0", + "command": r#"{"path":"/proj/src","pattern":"fn main","glob":"*.rs"}"#, + }); + + let formatted = adapter.format_agentmemory_payload("PreToolUse", raw_payload); + + assert_eq!(formatted["hookType"], "pre_tool_use"); + assert_eq!(formatted["data"]["tool_input"]["path"], "/proj/src"); + assert_eq!(formatted["data"]["tool_input"]["pattern"], "fn main"); + assert_eq!(formatted["data"]["files"][0], "/proj/src"); + assert_eq!(formatted["data"]["search_terms"][0], "fn main"); + assert_eq!(formatted["data"]["search_terms"][1], "*.rs"); + } + + #[test] + fn test_structured_tool_input_parsed_from_json_command() { + let adapter = AgentmemoryAdapter::new(); + let raw_payload = json!({ + "session_id": "s1", + "turn_id": "t1", + "cwd": "/proj", + "tool_name": "read_file", + "tool_use_id": "tu-1", + "command": r#"{"file_path":"/proj/src/main.rs","offset":1,"limit":50}"#, + "tool_response": { "text": "fn main() {}" } + }); + + let formatted = adapter.format_agentmemory_payload("PostToolUse", raw_payload); + + // tool_input should be the parsed object, not the raw string. + assert_eq!(formatted["data"]["tool_input"]["file_path"], "/proj/src/main.rs"); + assert_eq!(formatted["data"]["tool_input"]["offset"], 1); + // File enrichment should surface the path. + assert_eq!(formatted["data"]["files"][0], "/proj/src/main.rs"); + } + + #[test] + fn test_non_json_command_preserved_as_string() { + let adapter = AgentmemoryAdapter::new(); + let raw_payload = json!({ + "session_id": "s1", + "turn_id": "t1", + "cwd": "/proj", + "tool_name": "shell", + "tool_use_id": "tu-2", + "command": "ls -la /tmp", + "tool_response": { "output": "total 0" } + }); + + let formatted = adapter.format_agentmemory_payload("PostToolUse", raw_payload); + assert_eq!(formatted["data"]["tool_input"], "ls -la /tmp"); + // No file enrichment for plain commands. + assert!(formatted["data"].get("files").is_none()); + } + + #[test] + fn test_file_enrichment_extracts_paths_and_search_terms() { + let adapter = AgentmemoryAdapter::new(); + let raw_payload = json!({ + "session_id": "s1", + "turn_id": "t1", + "cwd": "/proj", + "tool_name": "grep", + "tool_use_id": "tu-3", + "command": r#"{"path":"/proj/src","pattern":"TODO","glob":"*.rs"}"#, + "tool_response": { "matches": [] } + }); + + let formatted = adapter.format_agentmemory_payload("PostToolUse", raw_payload); + + assert_eq!(formatted["data"]["files"][0], "/proj/src"); + assert_eq!(formatted["data"]["search_terms"][0], "TODO"); + assert_eq!(formatted["data"]["search_terms"][1], "*.rs"); + } + + #[test] + fn test_file_enrichment_on_failure_event() { + let adapter = AgentmemoryAdapter::new(); + let raw_payload = json!({ + "session_id": "s1", + "turn_id": "t1", + "cwd": "/proj", + "tool_name": "read_file", + "tool_use_id": "tu-4", + "command": r#"{"file_path":"/proj/missing.rs"}"#, + "tool_response": { "error": "file not found" } + }); + + let formatted = adapter.format_agentmemory_payload("PostToolUseFailure", raw_payload); + + assert_eq!(formatted["hookType"], "post_tool_failure"); + assert_eq!(formatted["data"]["files"][0], "/proj/missing.rs"); + assert_eq!(formatted["data"]["error"], "file not found"); + } + + #[test] + fn test_assistant_result_payload_shape() { + let adapter = AgentmemoryAdapter::new(); + let raw_payload = json!({ + "session_id": "s1", + "turn_id": "t1", + "cwd": "/proj", + "model": "claude-opus-4-6", + "assistant_text": "The build succeeded with no warnings.", + "is_final": true, + }); + + let formatted = adapter.format_agentmemory_payload("AssistantResult", raw_payload); + + assert_eq!(formatted["hookType"], "assistant_result"); + assert_eq!(formatted["sessionId"], "s1"); + assert_eq!(formatted["data"]["assistant_text"], "The build succeeded with no warnings."); + assert_eq!(formatted["data"]["is_final"], true); + assert_eq!(formatted["data"]["turn_id"], "t1"); + assert_eq!(formatted["data"]["model"], "claude-opus-4-6"); + } + + #[test] + fn test_assistant_result_truncates_long_text() { + let adapter = AgentmemoryAdapter::new(); + let long_text = "x".repeat(8000); + let raw_payload = json!({ + "session_id": "s1", + "turn_id": "t1", + "cwd": "/proj", + "model": "test", + "assistant_text": long_text, + }); + + let formatted = adapter.format_agentmemory_payload("AssistantResult", raw_payload); + let stored_text = formatted["data"]["assistant_text"].as_str().unwrap(); + assert!(stored_text.len() <= AgentmemoryAdapter::ASSISTANT_TEXT_MAX_BYTES); + } + + #[test] + fn test_paths_array_enrichment() { + let adapter = AgentmemoryAdapter::new(); + let raw_payload = json!({ + "session_id": "s1", + "turn_id": "t1", + "cwd": "/proj", + "tool_name": "multi_edit", + "tool_use_id": "tu-5", + "command": r#"{"paths":["/proj/a.rs","/proj/b.rs"]}"#, + "tool_response": { "ok": true } + }); + + let formatted = adapter.format_agentmemory_payload("PostToolUse", raw_payload); + + let files = formatted["data"]["files"].as_array().unwrap(); + assert_eq!(files.len(), 2); + assert_eq!(files[0], "/proj/a.rs"); + assert_eq!(files[1], "/proj/b.rs"); + } + #[test] fn test_api_base_prefers_explicit_agentmemory_url() { let _guard = ENV_LOCK.lock().expect("lock env"); diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 847a243d8..fb4f9c6a8 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -6017,14 +6017,40 @@ pub(crate) async fn run_turn( == crate::config::types::MemoryBackend::Agentmemory { let adapter = crate::agentmemory::AgentmemoryAdapter::new(); - let payload = stop_request.clone(); + let stop_payload = + serde_json::to_value(&stop_request).unwrap_or_default(); + + // Emit an assistant_result observation when the turn + // produced a meaningful assistant conclusion. This + // ensures sessions with little tool usage still create + // useful memory records. + let assistant_result_payload = + if let Some(ref text) = last_agent_message { + if !text.trim().is_empty() { + Some(serde_json::json!({ + "session_id": sess.conversation_id.to_string(), + "turn_id": turn_context.sub_id.clone(), + "cwd": turn_context.cwd.to_string_lossy().to_string(), + "model": turn_context.model_info.slug.clone(), + "assistant_text": text, + "is_final": true, + })) + } else { + None + } + } else { + None + }; + tokio::spawn(async move { adapter - .capture_event( - "Stop", - serde_json::to_value(&payload).unwrap_or_default(), - ) + .capture_event("Stop", stop_payload) .await; + if let Some(ar_payload) = assistant_result_payload { + adapter + .capture_event("AssistantResult", ar_payload) + .await; + } }); } diff --git a/codex-rs/docs/agentmemory_payload_quality_spec.md b/codex-rs/docs/agentmemory_payload_quality_spec.md index 1df493844..2b367352d 100644 --- a/codex-rs/docs/agentmemory_payload_quality_spec.md +++ b/codex-rs/docs/agentmemory_payload_quality_spec.md @@ -182,10 +182,10 @@ Acceptance criteria: ## Rollout Order -1. suppress or gate low-value lifecycle observations -2. forward richer structured tool input -3. add assistant-result capture -4. add evaluation fixtures and compare before/after quality +1. ~~suppress or gate low-value lifecycle observations~~ — kept all events including pre_tool_use; enriched with structured args instead of gating +2. ~~forward richer structured tool input~~ — implemented +3. ~~add assistant-result capture~~ — implemented +4. ~~add evaluation fixtures and compare before/after quality~~ — unit tests added; real-session fixture comparison is deferred ## Risks From 0c220fb634b29d492d2af74ac0bf29a7fd7c3f9d Mon Sep 17 00:00:00 2001 From: Eric Juta Date: Sat, 28 Mar 2026 00:43:27 +0000 Subject: [PATCH 22/45] feat: add mid-session memory recall and streaming assistant capture Add /memory-recall slash command for retrieving agentmemory context mid-conversation (with optional query scoping) and injecting results as developer messages. Also capture intermediate AssistantResult events (is_final=false) as each message block completes streaming, not just at turn end. Co-Authored-By: Claude Opus 4.6 (1M context) --- codex-rs/core/src/agentmemory/mod.rs | 42 +++++++ codex-rs/core/src/codex.rs | 106 ++++++++++++++++++ codex-rs/protocol/src/protocol.rs | 8 ++ codex-rs/tui/src/bottom_pane/command_popup.rs | 5 + .../tui/src/bottom_pane/slash_commands.rs | 11 +- codex-rs/tui/src/chatwidget.rs | 15 ++- codex-rs/tui/src/slash_command.rs | 5 + 7 files changed, 188 insertions(+), 4 deletions(-) diff --git a/codex-rs/core/src/agentmemory/mod.rs b/codex-rs/core/src/agentmemory/mod.rs index 11391fc09..0ee51590d 100644 --- a/codex-rs/core/src/agentmemory/mod.rs +++ b/codex-rs/core/src/agentmemory/mod.rs @@ -339,6 +339,48 @@ impl AgentmemoryAdapter { } } + /// Retrieves memory context mid-session via `agentmemory`'s hybrid search. + /// + /// Unlike `build_startup_developer_instructions`, this uses the real + /// session ID and an optional query to scope retrieval. + pub async fn recall_context( + &self, + session_id: &str, + project: &Path, + query: Option<&str>, + token_budget: usize, + ) -> Result { + let client = get_client(); + let url = format!("{}/agentmemory/context", self.api_base()); + + let mut body = json!({ + "sessionId": session_id, + "project": project.to_string_lossy(), + "budget": token_budget, + }); + if let Some(q) = query { + body["query"] = serde_json::Value::String(q.to_string()); + } + + let res = client + .post(&url) + .json(&body) + .send() + .await + .map_err(|e| e.to_string())?; + + if !res.status().is_success() { + return Err(format!("Context retrieval failed with status {}", res.status())); + } + + let json_res: serde_json::Value = res.json().await.map_err(|e| e.to_string())?; + Ok(json_res + .get("context") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string()) + } + /// Registers a session so Agentmemory's session-backed views can discover it. pub async fn start_session( &self, diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index fb4f9c6a8..4baf25559 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -4438,6 +4438,10 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv handlers::update_memories(&sess, &config, sub.id.clone()).await; false } + Op::RecallMemories { query } => { + handlers::recall_memories(&sess, &config, sub.id.clone(), query).await; + false + } Op::ThreadRollback { num_turns } => { handlers::thread_rollback(&sess, sub.id.clone(), num_turns).await; false @@ -4561,6 +4565,8 @@ mod handlers { use codex_protocol::protocol::ThreadRolledBackEvent; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::WarningEvent; + use codex_protocol::models::DeveloperInstructions; + use codex_protocol::models::ResponseItem; use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::request_user_input::RequestUserInputResponse; @@ -5171,6 +5177,68 @@ mod handlers { .await; } + pub async fn recall_memories( + sess: &Arc, + config: &Arc, + sub_id: String, + query: Option, + ) { + if config.memories.backend != crate::config::types::MemoryBackend::Agentmemory { + sess.send_event_raw(Event { + id: sub_id, + msg: EventMsg::Warning(WarningEvent { + message: "Memory recall requires agentmemory backend.".to_string(), + }), + }) + .await; + return; + } + + let adapter = crate::agentmemory::AgentmemoryAdapter::new(); + let session_id = sess.conversation_id.to_string(); + + match adapter + .recall_context(&session_id, config.cwd.as_ref(), query.as_deref(), 2000) + .await + { + Ok(context) if !context.trim().is_empty() => { + let turn_context = sess.new_default_turn_with_sub_id(sub_id.clone()).await; + let message: ResponseItem = DeveloperInstructions::new(format!( + "\n{context}\n" + )) + .into(); + sess.record_conversation_items(&turn_context, std::slice::from_ref(&message)) + .await; + sess.send_event_raw(Event { + id: sub_id, + msg: EventMsg::Warning(WarningEvent { + message: "Memory context recalled and injected.".to_string(), + }), + }) + .await; + } + Ok(_) => { + sess.send_event_raw(Event { + id: sub_id, + msg: EventMsg::Warning(WarningEvent { + message: "No relevant memory context found.".to_string(), + }), + }) + .await; + } + Err(e) => { + sess.send_event_raw(Event { + id: sub_id, + msg: EventMsg::Error(ErrorEvent { + message: format!("Memory recall failed: {e}"), + codex_error_info: Some(CodexErrorInfo::Other), + }), + }) + .await; + } + } + } + pub async fn thread_rollback(sess: &Arc, sub_id: String, num_turns: u32) { if num_turns == 0 { sess.send_event_raw(Event { @@ -7255,6 +7323,24 @@ async fn handle_assistant_item_done_in_plan_mode( record_completed_response_item(sess, turn_context, item).await; if let Some(agent_message) = last_assistant_message_from_item(item, /*plan_mode*/ true) { + // Capture intermediate assistant text in plan mode. + if turn_context.config.memories.backend + == crate::config::types::MemoryBackend::Agentmemory + && !agent_message.trim().is_empty() + { + let adapter = crate::agentmemory::AgentmemoryAdapter::new(); + let payload = serde_json::json!({ + "session_id": sess.conversation_id.to_string(), + "turn_id": turn_context.sub_id.clone(), + "cwd": turn_context.cwd.to_string_lossy().to_string(), + "model": turn_context.model_info.slug.clone(), + "assistant_text": agent_message, + "is_final": false, + }); + tokio::spawn(async move { + adapter.capture_event("AssistantResult", payload).await; + }); + } *last_agent_message = Some(agent_message); } return true; @@ -7410,6 +7496,26 @@ async fn try_run_sampling_request( in_flight.push_back(tool_future); } if let Some(agent_message) = output_result.last_agent_message { + // Capture intermediate assistant text to agentmemory as it + // streams (is_final=false). The final capture happens at + // turn completion with is_final=true. + if turn_context.config.memories.backend + == crate::config::types::MemoryBackend::Agentmemory + && !agent_message.trim().is_empty() + { + let adapter = crate::agentmemory::AgentmemoryAdapter::new(); + let payload = serde_json::json!({ + "session_id": sess.conversation_id.to_string(), + "turn_id": turn_context.sub_id.clone(), + "cwd": turn_context.cwd.to_string_lossy().to_string(), + "model": turn_context.model_info.slug.clone(), + "assistant_text": agent_message, + "is_final": false, + }); + tokio::spawn(async move { + adapter.capture_event("AssistantResult", payload).await; + }); + } last_agent_message = Some(agent_message); } needs_follow_up |= output_result.needs_follow_up; diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index c28cb4dfe..6b0b43fdf 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -479,6 +479,13 @@ pub enum Op { /// Trigger a single pass of the startup memory pipeline. UpdateMemories, + /// Retrieve memory context mid-session and inject as developer message. + RecallMemories { + /// Optional query string to scope the retrieval. + #[serde(default, skip_serializing_if = "Option::is_none")] + query: Option, + }, + /// Set a user-facing thread name in the persisted rollout metadata. /// This is a local-only operation handled by codex-core; it does not /// involve the model. @@ -592,6 +599,7 @@ impl Op { Self::Compact => "compact", Self::DropMemories => "drop_memories", Self::UpdateMemories => "update_memories", + Self::RecallMemories { .. } => "recall_memories", Self::SetThreadName { .. } => "set_thread_name", Self::Undo => "undo", Self::ThreadRollback { .. } => "thread_rollback", diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index ffd0702bc..3921de4b3 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -520,6 +520,7 @@ mod tests { realtime_conversation_enabled: false, audio_device_selection_enabled: false, windows_degraded_sandbox_active: false, + agentmemory_enabled: false, }, ); popup.on_composer_text_change("/collab".to_string()); @@ -543,6 +544,7 @@ mod tests { realtime_conversation_enabled: false, audio_device_selection_enabled: false, windows_degraded_sandbox_active: false, + agentmemory_enabled: false, }, ); popup.on_composer_text_change("/plan".to_string()); @@ -566,6 +568,7 @@ mod tests { realtime_conversation_enabled: false, audio_device_selection_enabled: false, windows_degraded_sandbox_active: false, + agentmemory_enabled: false, }, ); popup.on_composer_text_change("/pers".to_string()); @@ -597,6 +600,7 @@ mod tests { realtime_conversation_enabled: false, audio_device_selection_enabled: false, windows_degraded_sandbox_active: false, + agentmemory_enabled: false, }, ); popup.on_composer_text_change("/personality".to_string()); @@ -620,6 +624,7 @@ mod tests { realtime_conversation_enabled: true, audio_device_selection_enabled: false, windows_degraded_sandbox_active: false, + agentmemory_enabled: false, }, ); popup.on_composer_text_change("/aud".to_string()); diff --git a/codex-rs/tui/src/bottom_pane/slash_commands.rs b/codex-rs/tui/src/bottom_pane/slash_commands.rs index 0c14e57f4..c02ff44d9 100644 --- a/codex-rs/tui/src/bottom_pane/slash_commands.rs +++ b/codex-rs/tui/src/bottom_pane/slash_commands.rs @@ -38,7 +38,15 @@ pub(crate) fn builtins_for_input(flags: BuiltinCommandFlags) -> Vec<(&'static st .filter(|(_, cmd)| flags.personality_command_enabled || *cmd != SlashCommand::Personality) .filter(|(_, cmd)| flags.realtime_conversation_enabled || *cmd != SlashCommand::Realtime) .filter(|(_, cmd)| flags.audio_device_selection_enabled || *cmd != SlashCommand::Settings) - .filter(|(_, cmd)| flags.agentmemory_enabled || !matches!(*cmd, SlashCommand::MemoryDrop | SlashCommand::MemoryUpdate)) + .filter(|(_, cmd)| { + flags.agentmemory_enabled + || !matches!( + *cmd, + SlashCommand::MemoryDrop + | SlashCommand::MemoryUpdate + | SlashCommand::MemoryRecall + ) + }) .collect() } @@ -73,6 +81,7 @@ mod tests { realtime_conversation_enabled: true, audio_device_selection_enabled: true, allow_elevate_sandbox: true, + agentmemory_enabled: true, } } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 9b0c34218..d6188dec9 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -3834,7 +3834,7 @@ impl ChatWidget { widget.config.features.enabled(Feature::VoiceTranscription), ); widget.bottom_pane.set_agentmemory_enabled( - widget.config.memories.backend == codex_core::config::types::MemoryBackend::Agentmemory + widget.config.memories.backend == codex_core::config::types::MemoryBackend::Agentmemory, ); widget .bottom_pane @@ -4041,7 +4041,7 @@ impl ChatWidget { widget.config.features.enabled(Feature::VoiceTranscription), ); widget.bottom_pane.set_agentmemory_enabled( - widget.config.memories.backend == codex_core::config::types::MemoryBackend::Agentmemory + widget.config.memories.backend == codex_core::config::types::MemoryBackend::Agentmemory, ); widget .bottom_pane @@ -4240,7 +4240,7 @@ impl ChatWidget { widget.config.features.enabled(Feature::VoiceTranscription), ); widget.bottom_pane.set_agentmemory_enabled( - widget.config.memories.backend == codex_core::config::types::MemoryBackend::Agentmemory + widget.config.memories.backend == codex_core::config::types::MemoryBackend::Agentmemory, ); widget .bottom_pane @@ -4803,6 +4803,9 @@ impl ChatWidget { SlashCommand::MemoryUpdate => { self.submit_op(Op::UpdateMemories); } + SlashCommand::MemoryRecall => { + self.submit_op(Op::RecallMemories { query: None }); + } SlashCommand::Mcp => { self.add_mcp_output(); } @@ -4994,6 +4997,12 @@ impl ChatWidget { }); self.bottom_pane.drain_pending_submission_state(); } + SlashCommand::MemoryRecall if !trimmed.is_empty() => { + self.submit_op(Op::RecallMemories { + query: Some(trimmed.to_string()), + }); + self.bottom_pane.drain_pending_submission_state(); + } _ => self.dispatch_command(cmd), } } diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 5892f3443..b25578f9e 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -64,6 +64,8 @@ pub enum SlashCommand { MemoryDrop, #[strum(serialize = "memory-update")] MemoryUpdate, + #[strum(serialize = "memory-recall")] + MemoryRecall, } impl SlashCommand { @@ -94,6 +96,7 @@ impl SlashCommand { SlashCommand::Stop => "stop all background terminals", SlashCommand::MemoryDrop => "clear the active memory store", SlashCommand::MemoryUpdate => "sync and consolidate memories", + SlashCommand::MemoryRecall => "recall memories and inject into context", SlashCommand::Model => "choose what model and reasoning effort to use", SlashCommand::Fast => "toggle Fast mode to enable fastest inference at 2X plan usage", SlashCommand::Personality => "choose a communication style for Codex", @@ -133,6 +136,7 @@ impl SlashCommand { | SlashCommand::Plan | SlashCommand::Fast | SlashCommand::SandboxReadRoot + | SlashCommand::MemoryRecall ) } @@ -168,6 +172,7 @@ impl SlashCommand { | SlashCommand::Stop | SlashCommand::MemoryDrop | SlashCommand::MemoryUpdate + | SlashCommand::MemoryRecall | SlashCommand::Mcp | SlashCommand::Apps | SlashCommand::Plugins From cecb9ae79c7dfcc55dffb24474f6da524916d488 Mon Sep 17 00:00:00 2001 From: Eric Juta Date: Sat, 28 Mar 2026 00:45:21 +0000 Subject: [PATCH 23/45] style: apply rustfmt formatting Co-Authored-By: Claude Opus 4.6 (1M context) --- codex-rs/core/src/lib.rs | 2 +- codex-rs/core/src/tools/context.rs | 6 +++--- codex-rs/core/src/tools/handlers/shell.rs | 6 ++++-- codex-rs/core/src/tools/handlers/unified_exec.rs | 3 ++- codex-rs/core/src/tools/handlers/unified_exec_tests.rs | 4 ++-- codex-rs/core/src/tools/registry.rs | 8 +++----- codex-rs/hooks/src/engine/dispatcher.rs | 6 +++--- 7 files changed, 18 insertions(+), 17 deletions(-) diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 4789cd4c6..ccbaa4abd 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -57,9 +57,9 @@ pub use mcp_connection_manager::MCP_SANDBOX_STATE_CAPABILITY; pub use mcp_connection_manager::MCP_SANDBOX_STATE_METHOD; pub use mcp_connection_manager::SandboxState; pub use text_encoding::bytes_to_string_smart; +pub mod agentmemory; mod mcp_tool_call; mod memories; -pub mod agentmemory; pub mod mention_syntax; pub mod message_history; mod model_provider_info; diff --git a/codex-rs/core/src/tools/context.rs b/codex-rs/core/src/tools/context.rs index ca3cec533..cbab87d01 100644 --- a/codex-rs/core/src/tools/context.rs +++ b/codex-rs/core/src/tools/context.rs @@ -214,9 +214,9 @@ impl ToolOutput for FunctionToolOutput { } fn post_tool_use_response(&self, _call_id: &str, _payload: &ToolPayload) -> Option { - self.post_tool_use_response.clone().or_else(|| { - serde_json::to_value(&self.body).ok() - }) + self.post_tool_use_response + .clone() + .or_else(|| serde_json::to_value(&self.body).ok()) } } diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index 86e6e4f00..fc3fa7135 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -217,7 +217,8 @@ impl ToolHandler for ShellHandler { invocation: &ToolInvocation, result: &dyn ToolOutput, ) -> Option { - let tool_response = result.post_tool_use_response(&invocation.call_id, &invocation.payload)?; + let tool_response = + result.post_tool_use_response(&invocation.call_id, &invocation.payload)?; Some(PostToolUsePayload { tool_name: invocation.tool_name.clone(), command: shell_payload_command(&invocation.payload)?, @@ -327,7 +328,8 @@ impl ToolHandler for ShellCommandHandler { invocation: &ToolInvocation, result: &dyn ToolOutput, ) -> Option { - let tool_response = result.post_tool_use_response(&invocation.call_id, &invocation.payload)?; + let tool_response = + result.post_tool_use_response(&invocation.call_id, &invocation.payload)?; Some(PostToolUsePayload { tool_name: invocation.tool_name.clone(), command: shell_command_payload_command(&invocation.payload)?, diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index 3beb77acd..11876d087 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -153,7 +153,8 @@ impl ToolHandler for UnifiedExecHandler { return None; } - let tool_response = result.post_tool_use_response(&invocation.call_id, &invocation.payload)?; + let tool_response = + result.post_tool_use_response(&invocation.call_id, &invocation.payload)?; Some(PostToolUsePayload { tool_name: invocation.tool_name.clone(), command: args.cmd, diff --git a/codex-rs/core/src/tools/handlers/unified_exec_tests.rs b/codex-rs/core/src/tools/handlers/unified_exec_tests.rs index 1adad9965..b0bf4d8cc 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec_tests.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec_tests.rs @@ -210,7 +210,7 @@ async fn exec_command_pre_tool_use_payload_uses_raw_command() { payload, }), Some(crate::tools::registry::PreToolUsePayload { -tool_name: "exec_command".to_string(), + tool_name: "exec_command".to_string(), command: "printf exec command".to_string(), }) ); @@ -273,7 +273,7 @@ async fn exec_command_post_tool_use_payload_uses_output_for_noninteractive_one_s assert_eq!( UnifiedExecHandler.post_tool_use_payload(&invocation, &output), Some(crate::tools::registry::PostToolUsePayload { -tool_name: "exec_command".to_string(), + tool_name: "exec_command".to_string(), command: "echo three".to_string(), tool_response: serde_json::json!("three"), }) diff --git a/codex-rs/core/src/tools/registry.rs b/codex-rs/core/src/tools/registry.rs index 0c525fb24..c6deae11b 100644 --- a/codex-rs/core/src/tools/registry.rs +++ b/codex-rs/core/src/tools/registry.rs @@ -69,7 +69,8 @@ pub trait ToolHandler: Send + Sync { invocation: &ToolInvocation, result: &dyn ToolOutput, ) -> Option { - let tool_response = result.post_tool_use_response(&invocation.call_id, &invocation.payload)?; + let tool_response = + result.post_tool_use_response(&invocation.call_id, &invocation.payload)?; Some(PostToolUsePayload { tool_name: invocation.tool_name.clone(), command: invocation.payload.log_payload().into_owned(), @@ -363,10 +364,7 @@ impl ToolRegistry { let post_tool_use_payload = if success { let guard = response_cell.lock().await; guard.as_ref().and_then(|result| { - handler.post_tool_use_payload( - &invocation, - result.result.as_ref(), - ) + handler.post_tool_use_payload(&invocation, result.result.as_ref()) }) } else { None diff --git a/codex-rs/hooks/src/engine/dispatcher.rs b/codex-rs/hooks/src/engine/dispatcher.rs index ae064de6f..309005f26 100644 --- a/codex-rs/hooks/src/engine/dispatcher.rs +++ b/codex-rs/hooks/src/engine/dispatcher.rs @@ -114,9 +114,9 @@ pub(crate) fn completed_summary( fn scope_for_event(event_name: HookEventName) -> HookScope { match event_name { - HookEventName::SessionStart - | HookEventName::SessionEnd - | HookEventName::PreCompact + HookEventName::SessionStart + | HookEventName::SessionEnd + | HookEventName::PreCompact | HookEventName::TaskCompleted => HookScope::Thread, HookEventName::PreToolUse | HookEventName::PostToolUse From 5b5af2bb60b220b54804c2b8a54cbb5b726fd997 Mon Sep 17 00:00:00 2001 From: Eric Juta Date: Sat, 28 Mar 2026 00:47:15 +0000 Subject: [PATCH 24/45] docs: update agentmemory spec with mid-session recall and streaming capture status Co-Authored-By: Claude Opus 4.6 (1M context) --- .../docs/agentmemory_payload_quality_spec.md | 29 +++++++++++++------ 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/codex-rs/docs/agentmemory_payload_quality_spec.md b/codex-rs/docs/agentmemory_payload_quality_spec.md index 2b367352d..944ddeb29 100644 --- a/codex-rs/docs/agentmemory_payload_quality_spec.md +++ b/codex-rs/docs/agentmemory_payload_quality_spec.md @@ -25,20 +25,28 @@ What is already working: - `pre_tool_use` - `post_tool_use` - `post_tool_failure` + - `assistant_result` - `stop` - Prompt, tool input, tool output, and error fields are mapped into the canonical Agentmemory schema. +- Structured tool arguments are parsed from JSON command strings into + searchable top-level fields (`file_path`, `path`, `pattern`, `query`, etc.). +- File-aware enrichment surfaces `files[]` and `search_terms[]` on tool events. +- Assistant conclusions are captured at turn completion (`is_final: true`) and + progressively as each message block completes streaming (`is_final: false`). +- Mid-session memory retrieval via `/memory-recall [query]` injects recalled + context as developer messages into the active conversation. +- Token-budgeted context injection at session startup via `/agentmemory/context`. +- All event capture is non-blocking via `tokio::spawn`. +- Assistant text truncated to 4096 bytes respecting UTF-8 boundaries. Remaining gaps: -- `pre_tool_use` often contains only a shell-style command string instead of - structured tool arguments. -- assistant conclusions/final answers are not emitted as first-class memory - payloads. -- repeated lifecycle hooks create noisy timelines. -- file-enrichment opportunities are weaker than the standalone JavaScript hook - path because Codex does not yet forward structured file arguments in the same - way. +- Tool output payloads (`post_tool_use`) have no size cap and may cause + memory bloat for large file reads. +- `pre_tool_use` fires unconditionally for all tools; no selective filtering + to reduce timeline noise for low-signal events. +- Real-session quality evaluation fixtures are deferred (unit tests exist). ## Desired Outcomes @@ -184,8 +192,11 @@ Acceptance criteria: 1. ~~suppress or gate low-value lifecycle observations~~ — kept all events including pre_tool_use; enriched with structured args instead of gating 2. ~~forward richer structured tool input~~ — implemented -3. ~~add assistant-result capture~~ — implemented +3. ~~add assistant-result capture~~ — implemented; streaming intermediate capture added (`is_final: false` per completed message block) 4. ~~add evaluation fixtures and compare before/after quality~~ — unit tests added; real-session fixture comparison is deferred +5. ~~mid-session memory retrieval~~ — implemented via `/memory-recall [query]` slash command and `Op::RecallMemories` +6. tool output size caps — not yet implemented +7. selective pre_tool_use filtering — not yet implemented ## Risks From c5ef3dae7674e11e678c358341fa9ff1c6d83c37 Mon Sep 17 00:00:00 2001 From: Eric Juta Date: Sat, 28 Mar 2026 01:51:56 +0000 Subject: [PATCH 25/45] Fix agentmemory session lifecycle Register agentmemory sessions during session init and close them through a shared idempotent shutdown path, including when the submission loop exits because the op channel closes. Add focused lifecycle tests for agentmemory start/end requests and channel-close session finalization. Co-authored-by: Codex --- codex-rs/core/src/agentmemory/mod.rs | 131 ++++++++++++++++++++------- codex-rs/core/src/codex.rs | 100 +++++++++++++------- codex-rs/core/src/codex_tests.rs | 66 ++++++++++++++ codex-rs/core/src/hook_runtime.rs | 10 +- codex-rs/core/src/state/session.rs | 10 ++ codex-rs/core/src/tools/registry.rs | 22 ++--- 6 files changed, 249 insertions(+), 90 deletions(-) diff --git a/codex-rs/core/src/agentmemory/mod.rs b/codex-rs/core/src/agentmemory/mod.rs index 0ee51590d..65ddc3836 100644 --- a/codex-rs/core/src/agentmemory/mod.rs +++ b/codex-rs/core/src/agentmemory/mod.rs @@ -69,15 +69,13 @@ impl AgentmemoryAdapter { Your context is bounded; use targeted queries to expand details as needed." .to_string(); - if let Ok(res) = context_result { - if let Ok(json_res) = res.json::().await { - if let Some(context_str) = json_res.get("context").and_then(|v| v.as_str()) { - if !context_str.is_empty() { - instructions.push_str("\n\n"); - instructions.push_str(context_str); - } - } - } + if let Ok(res) = context_result + && let Ok(json_res) = res.json::().await + && let Some(context_str) = json_res.get("context").and_then(|v| v.as_str()) + && !context_str.is_empty() + { + instructions.push_str("\n\n"); + instructions.push_str(context_str); } Some(instructions) @@ -86,49 +84,46 @@ impl AgentmemoryAdapter { /// Attempts to parse a tool command string as JSON to recover structured /// arguments. Falls back to the original string value on parse failure. fn parse_structured_tool_input(raw: &serde_json::Value) -> serde_json::Value { - if let Some(s) = raw.as_str() { - if let Ok(parsed) = serde_json::from_str::(s) { - if parsed.is_object() { - return parsed; - } - } + if let Some(s) = raw.as_str() + && let Ok(parsed) = serde_json::from_str::(s) + && parsed.is_object() + { + return parsed; } raw.clone() } /// Extracts file paths and search terms from structured tool arguments /// so that Agentmemory observations mention the relevant paths and queries. - fn extract_file_enrichment( - tool_input: &serde_json::Value, - ) -> (Vec, Vec) { + fn extract_file_enrichment(tool_input: &serde_json::Value) -> (Vec, Vec) { let mut files: Vec = Vec::new(); let mut search_terms: Vec = Vec::new(); if let Some(obj) = tool_input.as_object() { // File path fields for key in &["file_path", "path", "dir_path"] { - if let Some(v) = obj.get(*key).and_then(|v| v.as_str()) { - if !v.is_empty() { - files.push(v.to_string()); - } + if let Some(v) = obj.get(*key).and_then(|v| v.as_str()) + && !v.is_empty() + { + files.push(v.to_string()); } } // Array of paths if let Some(arr) = obj.get("paths").and_then(|v| v.as_array()) { for item in arr { - if let Some(s) = item.as_str() { - if !s.is_empty() { - files.push(s.to_string()); - } + if let Some(s) = item.as_str() + && !s.is_empty() + { + files.push(s.to_string()); } } } // Search / pattern fields for key in &["query", "pattern", "glob"] { - if let Some(v) = obj.get(*key).and_then(|v| v.as_str()) { - if !v.is_empty() { - search_terms.push(v.to_string()); - } + if let Some(v) = obj.get(*key).and_then(|v| v.as_str()) + && !v.is_empty() + { + search_terms.push(v.to_string()); } } } @@ -290,8 +285,7 @@ impl AgentmemoryAdapter { .get("assistant_text") .and_then(|v| v.as_str()) .unwrap_or(""); - let truncated = - Self::truncate_text(assistant_text, Self::ASSISTANT_TEXT_MAX_BYTES); + let truncated = Self::truncate_text(assistant_text, Self::ASSISTANT_TEXT_MAX_BYTES); ( "assistant_result", json!({ @@ -370,7 +364,10 @@ impl AgentmemoryAdapter { .map_err(|e| e.to_string())?; if !res.status().is_success() { - return Err(format!("Context retrieval failed with status {}", res.status())); + return Err(format!( + "Context retrieval failed with status {}", + res.status() + )); } let json_res: serde_json::Value = res.json().await.map_err(|e| e.to_string())?; @@ -457,6 +454,12 @@ mod tests { use serde_json::json; use std::ffi::OsString; use std::sync::Mutex; + use wiremock::Mock; + use wiremock::MockServer; + use wiremock::ResponseTemplate; + use wiremock::matchers::body_json; + use wiremock::matchers::method; + use wiremock::matchers::path; static ENV_LOCK: Mutex<()> = Mutex::new(()); @@ -577,7 +580,10 @@ mod tests { let formatted = adapter.format_agentmemory_payload("PostToolUse", raw_payload); // tool_input should be the parsed object, not the raw string. - assert_eq!(formatted["data"]["tool_input"]["file_path"], "/proj/src/main.rs"); + assert_eq!( + formatted["data"]["tool_input"]["file_path"], + "/proj/src/main.rs" + ); assert_eq!(formatted["data"]["tool_input"]["offset"], 1); // File enrichment should surface the path. assert_eq!(formatted["data"]["files"][0], "/proj/src/main.rs"); @@ -658,7 +664,10 @@ mod tests { assert_eq!(formatted["hookType"], "assistant_result"); assert_eq!(formatted["sessionId"], "s1"); - assert_eq!(formatted["data"]["assistant_text"], "The build succeeded with no warnings."); + assert_eq!( + formatted["data"]["assistant_text"], + "The build succeeded with no warnings." + ); assert_eq!(formatted["data"]["is_final"], true); assert_eq!(formatted["data"]["turn_id"], "t1"); assert_eq!(formatted["data"]["model"], "claude-opus-4-6"); @@ -702,6 +711,58 @@ mod tests { assert_eq!(files[1], "/proj/b.rs"); } + #[tokio::test] + #[serial_test::serial(agentmemory_env)] + async fn test_start_session_posts_expected_payload() { + let server = MockServer::start().await; + let adapter = AgentmemoryAdapter::new(); + let _agentmemory_url_guard = EnvVarGuard::set("AGENTMEMORY_URL", server.uri().as_str()); + + Mock::given(method("POST")) + .and(path("/agentmemory/session/start")) + .and(body_json(json!({ + "sessionId": "session-1", + "project": "/tmp/project", + "cwd": "/tmp/project", + }))) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&server) + .await; + + adapter + .start_session( + "session-1", + Path::new("/tmp/project"), + Path::new("/tmp/project"), + ) + .await + .expect("session start should succeed"); + } + + #[tokio::test] + #[serial_test::serial(agentmemory_env)] + async fn test_end_session_posts_expected_payload() { + let server = MockServer::start().await; + let adapter = AgentmemoryAdapter::new(); + let _agentmemory_url_guard = EnvVarGuard::set("AGENTMEMORY_URL", server.uri().as_str()); + + Mock::given(method("POST")) + .and(path("/agentmemory/session/end")) + .and(body_json(json!({ + "sessionId": "session-1", + }))) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&server) + .await; + + adapter + .end_session("session-1") + .await + .expect("session end should succeed"); + } + #[test] fn test_api_base_prefers_explicit_agentmemory_url() { let _guard = ENV_LOCK.lock().expect("lock env"); diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 4baf25559..36ff5276f 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1272,6 +1272,40 @@ impl Session { Ok((network_proxy, session_network_proxy)) } + async fn start_agentmemory_session(&self, cwd: &Path) -> Result<(), String> { + let adapter = crate::agentmemory::AgentmemoryAdapter::new(); + let session_id = self.conversation_id.to_string(); + adapter.start_session(session_id.as_str(), cwd, cwd).await + } + + async fn end_agentmemory_session_if_needed(&self) { + if self.get_config().await.memories.backend + != crate::config::types::MemoryBackend::Agentmemory + { + return; + } + + let should_end = { + let state = self.state.lock().await; + !state.agentmemory_session_ended() + }; + if !should_end { + return; + } + + let adapter = crate::agentmemory::AgentmemoryAdapter::new(); + let session_id = self.conversation_id.to_string(); + match adapter.end_session(session_id.as_str()).await { + Ok(()) => { + let mut state = self.state.lock().await; + state.set_agentmemory_session_ended(true); + } + Err(err) => { + warn!("Agentmemory session end failed for {session_id}: {err}"); + } + } + } + /// Don't expand the number of mutated arguments on config. We are in the process of getting rid of it. pub(crate) fn build_per_turn_config(session_configuration: &SessionConfiguration) -> Config { // todo(aibrahim): store this state somewhere else so we don't need to mut config @@ -1950,6 +1984,17 @@ impl Session { sess.send_event_raw(event).await; } + if config.memories.backend == crate::config::types::MemoryBackend::Agentmemory + && let Err(err) = sess + .start_agentmemory_session(session_configuration.cwd.as_path()) + .await + { + warn!( + "Agentmemory session start failed for {}: {err}", + sess.conversation_id + ); + } + // Start the watcher after SessionConfigured so it cannot emit earlier events. sess.start_skills_watcher_listener(); // Construct sandbox_state before MCP startup so it can be sent to each @@ -4488,6 +4533,7 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv } // Also drain cached guardian state if the submission loop exits because // the channel closed without receiving an explicit shutdown op. + sess.end_agentmemory_session_if_needed().await; sess.guardian_review_session.shutdown().await; debug!("Agent loop exited"); } @@ -4548,6 +4594,8 @@ mod handlers { use crate::tasks::UserShellCommandTask; use crate::tasks::execute_user_shell_command; use codex_protocol::custom_prompts::CustomPrompt; + use codex_protocol::models::DeveloperInstructions; + use codex_protocol::models::ResponseItem; use codex_protocol::protocol::CodexErrorInfo; use codex_protocol::protocol::ErrorEvent; use codex_protocol::protocol::Event; @@ -4565,8 +4613,6 @@ mod handlers { use codex_protocol::protocol::ThreadRolledBackEvent; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::WarningEvent; - use codex_protocol::models::DeveloperInstructions; - use codex_protocol::models::ResponseItem; use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::request_user_input::RequestUserInputResponse; @@ -5411,15 +5457,7 @@ mod handlers { pub async fn shutdown(sess: &Arc, sub_id: String) -> bool { sess.abort_all_tasks(TurnAbortReason::Interrupted).await; - if sess.get_config().await.memories.backend - == crate::config::types::MemoryBackend::Agentmemory - { - let adapter = crate::agentmemory::AgentmemoryAdapter::new(); - let session_id = sess.conversation_id.to_string(); - if let Err(e) = adapter.end_session(session_id.as_str()).await { - warn!("Agentmemory session end failed for {session_id}: {e}"); - } - } + sess.end_agentmemory_session_if_needed().await; let _ = sess.conversation.shutdown().await; sess.services .unified_exec_manager @@ -6085,39 +6123,33 @@ pub(crate) async fn run_turn( == crate::config::types::MemoryBackend::Agentmemory { let adapter = crate::agentmemory::AgentmemoryAdapter::new(); - let stop_payload = - serde_json::to_value(&stop_request).unwrap_or_default(); + let stop_payload = serde_json::to_value(&stop_request).unwrap_or_default(); // Emit an assistant_result observation when the turn // produced a meaningful assistant conclusion. This // ensures sessions with little tool usage still create // useful memory records. - let assistant_result_payload = - if let Some(ref text) = last_agent_message { - if !text.trim().is_empty() { - Some(serde_json::json!({ - "session_id": sess.conversation_id.to_string(), - "turn_id": turn_context.sub_id.clone(), - "cwd": turn_context.cwd.to_string_lossy().to_string(), - "model": turn_context.model_info.slug.clone(), - "assistant_text": text, - "is_final": true, - })) - } else { - None - } + let assistant_result_payload = if let Some(ref text) = last_agent_message { + if !text.trim().is_empty() { + Some(serde_json::json!({ + "session_id": sess.conversation_id.to_string(), + "turn_id": turn_context.sub_id.clone(), + "cwd": turn_context.cwd.to_string_lossy().to_string(), + "model": turn_context.model_info.slug.clone(), + "assistant_text": text, + "is_final": true, + })) } else { None - }; + } + } else { + None + }; tokio::spawn(async move { - adapter - .capture_event("Stop", stop_payload) - .await; + adapter.capture_event("Stop", stop_payload).await; if let Some(ar_payload) = assistant_result_payload { - adapter - .capture_event("AssistantResult", ar_payload) - .await; + adapter.capture_event("AssistantResult", ar_payload).await; } }); } diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index de6489946..644d9b160 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -105,9 +105,16 @@ use rmcp::model::JsonObject; use rmcp::model::Tool; use serde::Deserialize; use serde_json::json; +use std::ffi::OsString; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration as StdDuration; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::body_json; +use wiremock::matchers::method; +use wiremock::matchers::path; #[path = "codex_tests_guardian.rs"] mod guardian_tests; @@ -118,6 +125,34 @@ fn expect_text_tool_output(output: &FunctionToolOutput) -> String { function_call_output_content_items_to_text(&output.body).unwrap_or_default() } +struct EnvVarGuard { + key: &'static str, + original: Option, +} + +impl EnvVarGuard { + fn set(key: &'static str, value: &str) -> Self { + let original = std::env::var_os(key); + unsafe { + std::env::set_var(key, value); + } + Self { key, original } + } +} + +impl Drop for EnvVarGuard { + fn drop(&mut self) { + match self.original.take() { + Some(value) => unsafe { + std::env::set_var(self.key, value); + }, + None => unsafe { + std::env::remove_var(self.key); + }, + } + } +} + struct InstructionsTestCase { slug: &'static str, expects_apply_patch_instructions: bool, @@ -3289,6 +3324,37 @@ async fn shutdown_and_wait_waits_when_shutdown_is_already_in_progress() { .expect("shutdown waiter"); } +#[tokio::test] +#[serial_test::serial(agentmemory_env)] +async fn submission_loop_closes_agentmemory_session_when_channel_closes() { + let server = MockServer::start().await; + let _agentmemory_url_guard = EnvVarGuard::set("AGENTMEMORY_URL", server.uri().as_str()); + + let (session, _turn_context) = make_session_and_context().await; + let mut config = (*session.get_config().await).clone(); + config.memories.backend = crate::config::types::MemoryBackend::Agentmemory; + { + let mut state = session.state.lock().await; + state.session_configuration.original_config_do_not_use = Arc::new(config.clone()); + } + let session = Arc::new(session); + + Mock::given(method("POST")) + .and(path("/agentmemory/session/end")) + .and(body_json(json!({ + "sessionId": session.conversation_id.to_string(), + }))) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&server) + .await; + + let (tx_sub, rx_sub) = async_channel::bounded::(1); + drop(tx_sub); + + submission_loop(session, Arc::new(config), rx_sub).await; +} + #[tokio::test] async fn shutdown_and_wait_shuts_down_cached_guardian_subagent() { let (parent_session, parent_turn_context) = make_session_and_context().await; diff --git a/codex-rs/core/src/hook_runtime.rs b/codex-rs/core/src/hook_runtime.rs index 3c5554bd1..3b88f9981 100644 --- a/codex-rs/core/src/hook_runtime.rs +++ b/codex-rs/core/src/hook_runtime.rs @@ -105,15 +105,7 @@ pub(crate) async fn run_pending_session_start_hooks( if turn_context.config.memories.backend == crate::config::types::MemoryBackend::Agentmemory { let adapter = crate::agentmemory::AgentmemoryAdapter::new(); let payload = request.clone(); - let session_id = request.session_id.to_string(); - let cwd = request.cwd.clone(); tokio::spawn(async move { - if let Err(e) = adapter - .start_session(session_id.as_str(), cwd.as_path(), cwd.as_path()) - .await - { - tracing::warn!("Agentmemory session start failed for {session_id}: {e}"); - } adapter .capture_event( "SessionStart", @@ -246,7 +238,7 @@ pub(crate) async fn run_post_tool_use_failure_hooks( if turn_context.config.memories.backend == crate::config::types::MemoryBackend::Agentmemory { let adapter = crate::agentmemory::AgentmemoryAdapter::new(); - let payload = request.clone(); + let payload = request; tokio::spawn(async move { adapter .capture_event( diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 1a1423616..c1e1f7b8b 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -32,6 +32,7 @@ pub(crate) struct SessionState { pub(crate) startup_prewarm: Option, pub(crate) active_connector_selection: HashSet, pub(crate) pending_session_start_source: Option, + agentmemory_session_ended: bool, granted_permissions: Option, } @@ -50,6 +51,7 @@ impl SessionState { startup_prewarm: None, active_connector_selection: HashSet::new(), pending_session_start_source: None, + agentmemory_session_ended: false, granted_permissions: None, } } @@ -206,6 +208,14 @@ impl SessionState { self.pending_session_start_source.take() } + pub(crate) fn agentmemory_session_ended(&self) -> bool { + self.agentmemory_session_ended + } + + pub(crate) fn set_agentmemory_session_ended(&mut self, ended: bool) { + self.agentmemory_session_ended = ended; + } + pub(crate) fn record_granted_permissions(&mut self, permissions: PermissionProfile) { self.granted_permissions = merge_permission_profiles(self.granted_permissions.as_ref(), Some(&permissions)); diff --git a/codex-rs/core/src/tools/registry.rs b/codex-rs/core/src/tools/registry.rs index c6deae11b..0fed55514 100644 --- a/codex-rs/core/src/tools/registry.rs +++ b/codex-rs/core/src/tools/registry.rs @@ -382,18 +382,16 @@ impl ToolRegistry { .await, ) } else { - if !success { - if let Some(ref payload) = pre_tool_use_payload { - crate::hook_runtime::run_post_tool_use_failure_hooks( - &invocation.session, - &invocation.turn, - payload.tool_name.clone(), - invocation.call_id.clone(), - payload.command.clone(), - output_preview.clone(), - ) - .await; - } + if !success && let Some(ref payload) = pre_tool_use_payload { + crate::hook_runtime::run_post_tool_use_failure_hooks( + &invocation.session, + &invocation.turn, + payload.tool_name.clone(), + invocation.call_id.clone(), + payload.command.clone(), + output_preview.clone(), + ) + .await; } None }; From dc8a2a82a6157333494cf756b6ecb2d1855004ae Mon Sep 17 00:00:00 2001 From: Eric Juta Date: Sat, 28 Mar 2026 02:55:44 +0000 Subject: [PATCH 26/45] tui app server parity --- .../schema/json/ClientRequest.json | 111 ++++++++++++++ .../schema/json/ServerNotification.json | 9 +- .../codex_app_server_protocol.schemas.json | 141 +++++++++++++++++- .../codex_app_server_protocol.v2.schemas.json | 141 +++++++++++++++++- .../json/v2/HookCompletedNotification.json | 9 +- .../json/v2/HookStartedNotification.json | 9 +- .../json/v2/ThreadMemoryDropParams.json | 13 ++ .../json/v2/ThreadMemoryDropResponse.json | 5 + .../json/v2/ThreadMemoryRecallParams.json | 19 +++ .../json/v2/ThreadMemoryRecallResponse.json | 5 + .../json/v2/ThreadMemoryUpdateParams.json | 13 ++ .../json/v2/ThreadMemoryUpdateResponse.json | 5 + .../schema/typescript/ClientRequest.ts | 5 +- .../schema/typescript/v2/HookEventName.ts | 2 +- .../typescript/v2/ThreadMemoryDropParams.ts | 5 + .../typescript/v2/ThreadMemoryDropResponse.ts | 5 + .../typescript/v2/ThreadMemoryRecallParams.ts | 5 + .../v2/ThreadMemoryRecallResponse.ts | 5 + .../typescript/v2/ThreadMemoryUpdateParams.ts | 5 + .../v2/ThreadMemoryUpdateResponse.ts | 5 + .../schema/typescript/v2/index.ts | 6 + .../src/protocol/common.rs | 77 ++++++++++ .../app-server-protocol/src/protocol/v2.rs | 38 +++++ codex-rs/app-server/README.md | 27 ++++ .../app-server/src/codex_message_processor.rs | 111 ++++++++++++++ codex-rs/tui_app_server/src/app.rs | 14 ++ codex-rs/tui_app_server/src/app_command.rs | 20 +++ .../tui_app_server/src/app_server_session.rs | 56 +++++++ .../src/bottom_pane/chat_composer.rs | 9 ++ .../src/bottom_pane/command_popup.rs | 7 + .../tui_app_server/src/bottom_pane/mod.rs | 5 + .../src/bottom_pane/slash_commands.rs | 18 +++ codex-rs/tui_app_server/src/chatwidget.rs | 21 ++- .../tui_app_server/src/chatwidget/tests.rs | 63 ++++---- codex-rs/tui_app_server/src/slash_command.rs | 21 ++- 35 files changed, 968 insertions(+), 42 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/json/v2/ThreadMemoryDropParams.json create mode 100644 codex-rs/app-server-protocol/schema/json/v2/ThreadMemoryDropResponse.json create mode 100644 codex-rs/app-server-protocol/schema/json/v2/ThreadMemoryRecallParams.json create mode 100644 codex-rs/app-server-protocol/schema/json/v2/ThreadMemoryRecallResponse.json create mode 100644 codex-rs/app-server-protocol/schema/json/v2/ThreadMemoryUpdateParams.json create mode 100644 codex-rs/app-server-protocol/schema/json/v2/ThreadMemoryUpdateResponse.json create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/ThreadMemoryDropParams.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/ThreadMemoryDropResponse.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/ThreadMemoryRecallParams.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/ThreadMemoryRecallResponse.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/ThreadMemoryUpdateParams.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/ThreadMemoryUpdateResponse.ts diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 9227a97d5..25a2837c9 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -2697,6 +2697,45 @@ }, "type": "object" }, + "ThreadMemoryDropParams": { + "properties": { + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "type": "object" + }, + "ThreadMemoryRecallParams": { + "properties": { + "query": { + "type": [ + "string", + "null" + ] + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "type": "object" + }, + "ThreadMemoryUpdateParams": { + "properties": { + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "type": "object" + }, "ThreadMetadataGitInfoUpdateParams": { "properties": { "branch": { @@ -3661,6 +3700,78 @@ "title": "Thread/compact/startRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "thread/memory/drop" + ], + "title": "Thread/memory/dropRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadMemoryDropParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/memory/dropRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "thread/memory/update" + ], + "title": "Thread/memory/updateRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadMemoryUpdateParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/memory/updateRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "thread/memory/recall" + ], + "title": "Thread/memory/recallRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadMemoryRecallParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/memory/recallRequest", + "type": "object" + }, { "properties": { "id": { diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 8ca93137c..f89ba681c 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -1207,9 +1207,16 @@ "enum": [ "preToolUse", "postToolUse", + "postToolUseFailure", + "preCompact", "sessionStart", + "subagentStart", + "subagentStop", + "notification", + "taskCompleted", "userPromptSubmit", - "stop" + "stop", + "sessionEnd" ], "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 6253c3929..72ad8d9d7 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -503,6 +503,78 @@ "title": "Thread/compact/startRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "thread/memory/drop" + ], + "title": "Thread/memory/dropRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/ThreadMemoryDropParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/memory/dropRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "thread/memory/update" + ], + "title": "Thread/memory/updateRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/ThreadMemoryUpdateParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/memory/updateRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "thread/memory/recall" + ], + "title": "Thread/memory/recallRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/ThreadMemoryRecallParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/memory/recallRequest", + "type": "object" + }, { "properties": { "id": { @@ -8222,9 +8294,16 @@ "enum": [ "preToolUse", "postToolUse", + "postToolUseFailure", + "preCompact", "sessionStart", + "subagentStart", + "subagentStop", + "notification", + "taskCompleted", "userPromptSubmit", - "stop" + "stop", + "sessionEnd" ], "type": "string" }, @@ -13112,6 +13191,66 @@ "title": "ThreadLoadedListResponse", "type": "object" }, + "ThreadMemoryDropParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadMemoryDropParams", + "type": "object" + }, + "ThreadMemoryDropResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ThreadMemoryDropResponse", + "type": "object" + }, + "ThreadMemoryRecallParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "query": { + "type": [ + "string", + "null" + ] + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadMemoryRecallParams", + "type": "object" + }, + "ThreadMemoryRecallResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ThreadMemoryRecallResponse", + "type": "object" + }, + "ThreadMemoryUpdateParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadMemoryUpdateParams", + "type": "object" + }, + "ThreadMemoryUpdateResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ThreadMemoryUpdateResponse", + "type": "object" + }, "ThreadMetadataGitInfoUpdateParams": { "properties": { "branch": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 5d053604f..8b92e328d 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -1033,6 +1033,78 @@ "title": "Thread/compact/startRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "thread/memory/drop" + ], + "title": "Thread/memory/dropRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadMemoryDropParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/memory/dropRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "thread/memory/update" + ], + "title": "Thread/memory/updateRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadMemoryUpdateParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/memory/updateRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "thread/memory/recall" + ], + "title": "Thread/memory/recallRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadMemoryRecallParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/memory/recallRequest", + "type": "object" + }, { "properties": { "id": { @@ -4893,9 +4965,16 @@ "enum": [ "preToolUse", "postToolUse", + "postToolUseFailure", + "preCompact", "sessionStart", + "subagentStart", + "subagentStop", + "notification", + "taskCompleted", "userPromptSubmit", - "stop" + "stop", + "sessionEnd" ], "type": "string" }, @@ -10859,6 +10938,66 @@ "title": "ThreadLoadedListResponse", "type": "object" }, + "ThreadMemoryDropParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadMemoryDropParams", + "type": "object" + }, + "ThreadMemoryDropResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ThreadMemoryDropResponse", + "type": "object" + }, + "ThreadMemoryRecallParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "query": { + "type": [ + "string", + "null" + ] + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadMemoryRecallParams", + "type": "object" + }, + "ThreadMemoryRecallResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ThreadMemoryRecallResponse", + "type": "object" + }, + "ThreadMemoryUpdateParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadMemoryUpdateParams", + "type": "object" + }, + "ThreadMemoryUpdateResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ThreadMemoryUpdateResponse", + "type": "object" + }, "ThreadMetadataGitInfoUpdateParams": { "properties": { "branch": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json index bce797086..2c9a51026 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json @@ -5,9 +5,16 @@ "enum": [ "preToolUse", "postToolUse", + "postToolUseFailure", + "preCompact", "sessionStart", + "subagentStart", + "subagentStop", + "notification", + "taskCompleted", "userPromptSubmit", - "stop" + "stop", + "sessionEnd" ], "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json index 72f32d0d9..ccf3fff13 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json @@ -5,9 +5,16 @@ "enum": [ "preToolUse", "postToolUse", + "postToolUseFailure", + "preCompact", "sessionStart", + "subagentStart", + "subagentStop", + "notification", + "taskCompleted", "userPromptSubmit", - "stop" + "stop", + "sessionEnd" ], "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadMemoryDropParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadMemoryDropParams.json new file mode 100644 index 000000000..81397e7be --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMemoryDropParams.json @@ -0,0 +1,13 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadMemoryDropParams", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadMemoryDropResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadMemoryDropResponse.json new file mode 100644 index 000000000..c2153610d --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMemoryDropResponse.json @@ -0,0 +1,5 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ThreadMemoryDropResponse", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadMemoryRecallParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadMemoryRecallParams.json new file mode 100644 index 000000000..564d2ce1f --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMemoryRecallParams.json @@ -0,0 +1,19 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "query": { + "type": [ + "string", + "null" + ] + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadMemoryRecallParams", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadMemoryRecallResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadMemoryRecallResponse.json new file mode 100644 index 000000000..aca6179da --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMemoryRecallResponse.json @@ -0,0 +1,5 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ThreadMemoryRecallResponse", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadMemoryUpdateParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadMemoryUpdateParams.json new file mode 100644 index 000000000..a5b97be12 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMemoryUpdateParams.json @@ -0,0 +1,13 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadMemoryUpdateParams", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadMemoryUpdateResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadMemoryUpdateResponse.json new file mode 100644 index 000000000..3bbb1036e --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMemoryUpdateResponse.json @@ -0,0 +1,5 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ThreadMemoryUpdateResponse", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts index e33a98635..f13173319 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts @@ -47,6 +47,9 @@ import type { ThreadCompactStartParams } from "./v2/ThreadCompactStartParams"; import type { ThreadForkParams } from "./v2/ThreadForkParams"; import type { ThreadListParams } from "./v2/ThreadListParams"; import type { ThreadLoadedListParams } from "./v2/ThreadLoadedListParams"; +import type { ThreadMemoryDropParams } from "./v2/ThreadMemoryDropParams"; +import type { ThreadMemoryRecallParams } from "./v2/ThreadMemoryRecallParams"; +import type { ThreadMemoryUpdateParams } from "./v2/ThreadMemoryUpdateParams"; import type { ThreadMetadataUpdateParams } from "./v2/ThreadMetadataUpdateParams"; import type { ThreadReadParams } from "./v2/ThreadReadParams"; import type { ThreadResumeParams } from "./v2/ThreadResumeParams"; @@ -64,4 +67,4 @@ import type { WindowsSandboxSetupStartParams } from "./v2/WindowsSandboxSetupSta /** * Request from the client to the server. */ -export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; +export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/memory/drop", id: RequestId, params: ThreadMemoryDropParams, } | { "method": "thread/memory/update", id: RequestId, params: ThreadMemoryUpdateParams, } | { "method": "thread/memory/recall", id: RequestId, params: ThreadMemoryRecallParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/HookEventName.ts b/codex-rs/app-server-protocol/schema/typescript/v2/HookEventName.ts index b97c709b9..1e27ff589 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/HookEventName.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/HookEventName.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type HookEventName = "preToolUse" | "postToolUse" | "sessionStart" | "userPromptSubmit" | "stop"; +export type HookEventName = "preToolUse" | "postToolUse" | "postToolUseFailure" | "preCompact" | "sessionStart" | "subagentStart" | "subagentStop" | "notification" | "taskCompleted" | "userPromptSubmit" | "stop" | "sessionEnd"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadMemoryDropParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadMemoryDropParams.ts new file mode 100644 index 000000000..a05738c9f --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadMemoryDropParams.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ThreadMemoryDropParams = { threadId: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadMemoryDropResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadMemoryDropResponse.ts new file mode 100644 index 000000000..1b9ef88bb --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadMemoryDropResponse.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ThreadMemoryDropResponse = Record; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadMemoryRecallParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadMemoryRecallParams.ts new file mode 100644 index 000000000..176406cb6 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadMemoryRecallParams.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ThreadMemoryRecallParams = { threadId: string, query?: string | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadMemoryRecallResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadMemoryRecallResponse.ts new file mode 100644 index 000000000..c06b2c108 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadMemoryRecallResponse.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ThreadMemoryRecallResponse = Record; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadMemoryUpdateParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadMemoryUpdateParams.ts new file mode 100644 index 000000000..129a95bc9 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadMemoryUpdateParams.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ThreadMemoryUpdateParams = { threadId: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadMemoryUpdateResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadMemoryUpdateResponse.ts new file mode 100644 index 000000000..3dddb3fb4 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadMemoryUpdateResponse.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ThreadMemoryUpdateResponse = Record; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index d0b1b8819..09207eff6 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -278,6 +278,12 @@ export type { ThreadListParams } from "./ThreadListParams"; export type { ThreadListResponse } from "./ThreadListResponse"; export type { ThreadLoadedListParams } from "./ThreadLoadedListParams"; export type { ThreadLoadedListResponse } from "./ThreadLoadedListResponse"; +export type { ThreadMemoryDropParams } from "./ThreadMemoryDropParams"; +export type { ThreadMemoryDropResponse } from "./ThreadMemoryDropResponse"; +export type { ThreadMemoryRecallParams } from "./ThreadMemoryRecallParams"; +export type { ThreadMemoryRecallResponse } from "./ThreadMemoryRecallResponse"; +export type { ThreadMemoryUpdateParams } from "./ThreadMemoryUpdateParams"; +export type { ThreadMemoryUpdateResponse } from "./ThreadMemoryUpdateResponse"; export type { ThreadMetadataGitInfoUpdateParams } from "./ThreadMetadataGitInfoUpdateParams"; export type { ThreadMetadataUpdateParams } from "./ThreadMetadataUpdateParams"; export type { ThreadMetadataUpdateResponse } from "./ThreadMetadataUpdateResponse"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 5a9215f2e..fd2025e87 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -257,6 +257,18 @@ client_request_definitions! { params: v2::ThreadCompactStartParams, response: v2::ThreadCompactStartResponse, }, + ThreadMemoryDrop => "thread/memory/drop" { + params: v2::ThreadMemoryDropParams, + response: v2::ThreadMemoryDropResponse, + }, + ThreadMemoryUpdate => "thread/memory/update" { + params: v2::ThreadMemoryUpdateParams, + response: v2::ThreadMemoryUpdateResponse, + }, + ThreadMemoryRecall => "thread/memory/recall" { + params: v2::ThreadMemoryRecallParams, + response: v2::ThreadMemoryRecallResponse, + }, ThreadShellCommand => "thread/shellCommand" { params: v2::ThreadShellCommandParams, response: v2::ThreadShellCommandResponse, @@ -1553,6 +1565,71 @@ mod tests { Ok(()) } + #[test] + fn serialize_thread_memory_drop() -> Result<()> { + let request = ClientRequest::ThreadMemoryDrop { + request_id: RequestId::Integer(8), + params: v2::ThreadMemoryDropParams { + thread_id: "thr_123".to_string(), + }, + }; + assert_eq!( + json!({ + "method": "thread/memory/drop", + "id": 8, + "params": { + "threadId": "thr_123" + } + }), + serde_json::to_value(&request)?, + ); + Ok(()) + } + + #[test] + fn serialize_thread_memory_update() -> Result<()> { + let request = ClientRequest::ThreadMemoryUpdate { + request_id: RequestId::Integer(8), + params: v2::ThreadMemoryUpdateParams { + thread_id: "thr_123".to_string(), + }, + }; + assert_eq!( + json!({ + "method": "thread/memory/update", + "id": 8, + "params": { + "threadId": "thr_123" + } + }), + serde_json::to_value(&request)?, + ); + Ok(()) + } + + #[test] + fn serialize_thread_memory_recall() -> Result<()> { + let request = ClientRequest::ThreadMemoryRecall { + request_id: RequestId::Integer(8), + params: v2::ThreadMemoryRecallParams { + thread_id: "thr_123".to_string(), + query: Some("search term".to_string()), + }, + }; + assert_eq!( + json!({ + "method": "thread/memory/recall", + "id": 8, + "params": { + "threadId": "thr_123", + "query": "search term" + } + }), + serde_json::to_value(&request)?, + ); + Ok(()) + } + #[test] fn serialize_thread_realtime_start() -> Result<()> { let request = ClientRequest::ThreadRealtimeStart { diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index f32fe0463..4b7da537a 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -2973,6 +2973,44 @@ pub struct ThreadCompactStartParams { #[ts(export_to = "v2/")] pub struct ThreadCompactStartResponse {} +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadMemoryDropParams { + pub thread_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadMemoryDropResponse {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadMemoryUpdateParams { + pub thread_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadMemoryUpdateResponse {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadMemoryRecallParams { + pub thread_id: String, + #[ts(optional = nullable)] + pub query: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadMemoryRecallResponse {} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 1ca468827..8c33c7807 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -145,6 +145,9 @@ Example with notification opt-out: - `thread/name/set` — set or update a thread’s user-facing name for either a loaded thread or a persisted rollout; returns `{}` on success and emits `thread/name/updated` to initialized, opted-in clients. Thread names are not required to be unique; name lookups resolve to the most recently updated thread. - `thread/unarchive` — move an archived rollout file back into the sessions directory; returns the restored `thread` on success and emits `thread/unarchived`. - `thread/compact/start` — trigger conversation history compaction for a thread; returns `{}` immediately while progress streams through standard turn/item notifications. +- `thread/memory/drop` — clear the active memory store using the thread's configured memory backend; returns `{}` when the request is accepted. +- `thread/memory/update` — trigger a memory sync/consolidation pass using the thread's configured memory backend; returns `{}` when the request is accepted. +- `thread/memory/recall` — retrieve memory context for a thread and inject it into that thread as developer instructions; accepts optional `query` and returns `{}` when the recall request is accepted. - `thread/shellCommand` — run a user-initiated `!` shell command against a thread; this runs unsandboxed with full access rather than inheriting the thread sandbox policy. Returns `{}` immediately while progress streams through standard turn/item notifications and any active turn receives the formatted output in its message stream. - `thread/backgroundTerminals/clean` — terminate all running background terminals for a thread (experimental; requires `capabilities.experimentalApi`); returns `{}` when the cleanup request is accepted. - `thread/rollback` — drop the last N turns from the agent’s in-memory context and persist a rollback marker in the rollout so future resumes see the pruned history; returns the updated `thread` (with `turns` populated) on success. @@ -454,6 +457,30 @@ If the thread does not already have an active turn, the server starts a standalo { "id": 26, "result": {} } ``` +### Example: Manage thread memory + +Use the thread-scoped memory methods to mirror the legacy TUI slash commands: + +- `thread/memory/drop` clears the active memory store for the configured backend. +- `thread/memory/update` triggers a backend-specific sync/consolidation pass. +- `thread/memory/recall` retrieves memory context and injects it into the thread as developer instructions. + +All three requests return immediately with `{}`. Result details surface through the normal thread event stream as warning/error items, just like the equivalent core ops. + +```json +{ "method": "thread/memory/drop", "id": 27, "params": { "threadId": "thr_b" } } +{ "id": 27, "result": {} } + +{ "method": "thread/memory/update", "id": 28, "params": { "threadId": "thr_b" } } +{ "id": 28, "result": {} } + +{ "method": "thread/memory/recall", "id": 29, "params": { + "threadId": "thr_b", + "query": "recent auth failures" +} } +{ "id": 29, "result": {} } +``` + ### Example: Start a turn (send user input) Turns attach user input (text or images) to a thread and trigger Codex generation. The `input` field is a list of discriminated unions: diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 620a85a09..0bc00a255 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -117,6 +117,12 @@ use codex_app_server_protocol::ThreadBackgroundTerminalsCleanResponse; use codex_app_server_protocol::ThreadClosedNotification; use codex_app_server_protocol::ThreadCompactStartParams; use codex_app_server_protocol::ThreadCompactStartResponse; +use codex_app_server_protocol::ThreadMemoryDropParams; +use codex_app_server_protocol::ThreadMemoryDropResponse; +use codex_app_server_protocol::ThreadMemoryRecallParams; +use codex_app_server_protocol::ThreadMemoryRecallResponse; +use codex_app_server_protocol::ThreadMemoryUpdateParams; +use codex_app_server_protocol::ThreadMemoryUpdateResponse; use codex_app_server_protocol::ThreadDecrementElicitationParams; use codex_app_server_protocol::ThreadDecrementElicitationResponse; use codex_app_server_protocol::ThreadForkParams; @@ -711,6 +717,18 @@ impl CodexMessageProcessor { self.thread_compact_start(to_connection_request_id(request_id), params) .await; } + ClientRequest::ThreadMemoryDrop { request_id, params } => { + self.thread_memory_drop(to_connection_request_id(request_id), params) + .await; + } + ClientRequest::ThreadMemoryUpdate { request_id, params } => { + self.thread_memory_update(to_connection_request_id(request_id), params) + .await; + } + ClientRequest::ThreadMemoryRecall { request_id, params } => { + self.thread_memory_recall(to_connection_request_id(request_id), params) + .await; + } ClientRequest::ThreadBackgroundTerminalsClean { request_id, params } => { self.thread_background_terminals_clean( to_connection_request_id(request_id), @@ -3053,6 +3071,99 @@ impl CodexMessageProcessor { } } + async fn thread_memory_drop( + &self, + request_id: ConnectionRequestId, + params: ThreadMemoryDropParams, + ) { + let ThreadMemoryDropParams { thread_id } = params; + + let (_, thread) = match self.load_thread(&thread_id).await { + Ok(v) => v, + Err(error) => { + self.outgoing.send_error(request_id, error).await; + return; + } + }; + + match self + .submit_core_op(&request_id, thread.as_ref(), Op::DropMemories) + .await + { + Ok(_) => { + self.outgoing + .send_response(request_id, ThreadMemoryDropResponse {}) + .await; + } + Err(err) => { + self.send_internal_error(request_id, format!("failed to drop memories: {err}")) + .await; + } + } + } + + async fn thread_memory_update( + &self, + request_id: ConnectionRequestId, + params: ThreadMemoryUpdateParams, + ) { + let ThreadMemoryUpdateParams { thread_id } = params; + + let (_, thread) = match self.load_thread(&thread_id).await { + Ok(v) => v, + Err(error) => { + self.outgoing.send_error(request_id, error).await; + return; + } + }; + + match self + .submit_core_op(&request_id, thread.as_ref(), Op::UpdateMemories) + .await + { + Ok(_) => { + self.outgoing + .send_response(request_id, ThreadMemoryUpdateResponse {}) + .await; + } + Err(err) => { + self.send_internal_error(request_id, format!("failed to update memories: {err}")) + .await; + } + } + } + + async fn thread_memory_recall( + &self, + request_id: ConnectionRequestId, + params: ThreadMemoryRecallParams, + ) { + let ThreadMemoryRecallParams { thread_id, query } = params; + + let (_, thread) = match self.load_thread(&thread_id).await { + Ok(v) => v, + Err(error) => { + self.outgoing.send_error(request_id, error).await; + return; + } + }; + + match self + .submit_core_op(&request_id, thread.as_ref(), Op::RecallMemories { query }) + .await + { + Ok(_) => { + self.outgoing + .send_response(request_id, ThreadMemoryRecallResponse {}) + .await; + } + Err(err) => { + self.send_internal_error(request_id, format!("failed to recall memories: {err}")) + .await; + } + } + } + async fn thread_shell_command( &self, request_id: ConnectionRequestId, diff --git a/codex-rs/tui_app_server/src/app.rs b/codex-rs/tui_app_server/src/app.rs index dbef11ebc..e6433c823 100644 --- a/codex-rs/tui_app_server/src/app.rs +++ b/codex-rs/tui_app_server/src/app.rs @@ -2131,6 +2131,20 @@ impl App { app_server.thread_compact_start(thread_id).await?; Ok(true) } + AppCommandView::MemoryDrop => { + app_server.thread_memory_drop(thread_id).await?; + Ok(true) + } + AppCommandView::MemoryUpdate => { + app_server.thread_memory_update(thread_id).await?; + Ok(true) + } + AppCommandView::MemoryRecall { query } => { + app_server + .thread_memory_recall(thread_id, query.clone()) + .await?; + Ok(true) + } AppCommandView::SetThreadName { name } => { app_server .thread_set_name(thread_id, name.to_string()) diff --git a/codex-rs/tui_app_server/src/app_command.rs b/codex-rs/tui_app_server/src/app_command.rs index e01a25027..dea7b2b48 100644 --- a/codex-rs/tui_app_server/src/app_command.rs +++ b/codex-rs/tui_app_server/src/app_command.rs @@ -95,6 +95,11 @@ pub(crate) enum AppCommandView<'a> { force_reload: bool, }, Compact, + MemoryDrop, + MemoryUpdate, + MemoryRecall { + query: &'a Option, + }, SetThreadName { name: &'a str, }, @@ -256,6 +261,18 @@ impl AppCommand { Self(Op::Compact) } + pub(crate) fn memory_drop() -> Self { + Self(Op::DropMemories) + } + + pub(crate) fn memory_update() -> Self { + Self(Op::UpdateMemories) + } + + pub(crate) fn memory_recall(query: Option) -> Self { + Self(Op::RecallMemories { query }) + } + pub(crate) fn set_thread_name(name: String) -> Self { Self(Op::SetThreadName { name }) } @@ -388,6 +405,9 @@ impl AppCommand { force_reload: *force_reload, }, Op::Compact => AppCommandView::Compact, + Op::DropMemories => AppCommandView::MemoryDrop, + Op::UpdateMemories => AppCommandView::MemoryUpdate, + Op::RecallMemories { query } => AppCommandView::MemoryRecall { query }, Op::SetThreadName { name } => AppCommandView::SetThreadName { name }, Op::Shutdown => AppCommandView::Shutdown, Op::ThreadRollback { num_turns } => AppCommandView::ThreadRollback { diff --git a/codex-rs/tui_app_server/src/app_server_session.rs b/codex-rs/tui_app_server/src/app_server_session.rs index cf325786b..f37a029e3 100644 --- a/codex-rs/tui_app_server/src/app_server_session.rs +++ b/codex-rs/tui_app_server/src/app_server_session.rs @@ -25,6 +25,12 @@ use codex_app_server_protocol::ThreadBackgroundTerminalsCleanParams; use codex_app_server_protocol::ThreadBackgroundTerminalsCleanResponse; use codex_app_server_protocol::ThreadCompactStartParams; use codex_app_server_protocol::ThreadCompactStartResponse; +use codex_app_server_protocol::ThreadMemoryDropParams; +use codex_app_server_protocol::ThreadMemoryDropResponse; +use codex_app_server_protocol::ThreadMemoryRecallParams; +use codex_app_server_protocol::ThreadMemoryRecallResponse; +use codex_app_server_protocol::ThreadMemoryUpdateParams; +use codex_app_server_protocol::ThreadMemoryUpdateResponse; use codex_app_server_protocol::ThreadForkParams; use codex_app_server_protocol::ThreadForkResponse; use codex_app_server_protocol::ThreadListParams; @@ -524,6 +530,56 @@ impl AppServerSession { Ok(()) } + pub(crate) async fn thread_memory_drop(&mut self, thread_id: ThreadId) -> Result<()> { + let request_id = self.next_request_id(); + let _: ThreadMemoryDropResponse = self + .client + .request_typed(ClientRequest::ThreadMemoryDrop { + request_id, + params: ThreadMemoryDropParams { + thread_id: thread_id.to_string(), + }, + }) + .await + .wrap_err("thread/memory/drop failed in app-server TUI")?; + Ok(()) + } + + pub(crate) async fn thread_memory_update(&mut self, thread_id: ThreadId) -> Result<()> { + let request_id = self.next_request_id(); + let _: ThreadMemoryUpdateResponse = self + .client + .request_typed(ClientRequest::ThreadMemoryUpdate { + request_id, + params: ThreadMemoryUpdateParams { + thread_id: thread_id.to_string(), + }, + }) + .await + .wrap_err("thread/memory/update failed in app-server TUI")?; + Ok(()) + } + + pub(crate) async fn thread_memory_recall( + &mut self, + thread_id: ThreadId, + query: Option, + ) -> Result<()> { + let request_id = self.next_request_id(); + let _: ThreadMemoryRecallResponse = self + .client + .request_typed(ClientRequest::ThreadMemoryRecall { + request_id, + params: ThreadMemoryRecallParams { + thread_id: thread_id.to_string(), + query, + }, + }) + .await + .wrap_err("thread/memory/recall failed in app-server TUI")?; + Ok(()) + } + pub(crate) async fn thread_shell_command( &mut self, thread_id: ThreadId, diff --git a/codex-rs/tui_app_server/src/bottom_pane/chat_composer.rs b/codex-rs/tui_app_server/src/bottom_pane/chat_composer.rs index fea3b8c2a..b8273b470 100644 --- a/codex-rs/tui_app_server/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui_app_server/src/bottom_pane/chat_composer.rs @@ -406,6 +406,7 @@ pub(crate) struct ChatComposer { plugins_command_enabled: bool, fast_command_enabled: bool, personality_command_enabled: bool, + agentmemory_enabled: bool, realtime_conversation_enabled: bool, audio_device_selection_enabled: bool, windows_degraded_sandbox_active: bool, @@ -445,6 +446,7 @@ impl ChatComposer { plugins_command_enabled: self.plugins_command_enabled, fast_command_enabled: self.fast_command_enabled, personality_command_enabled: self.personality_command_enabled, + agentmemory_enabled: self.agentmemory_enabled, realtime_conversation_enabled: self.realtime_conversation_enabled, audio_device_selection_enabled: self.audio_device_selection_enabled, allow_elevate_sandbox: self.windows_degraded_sandbox_active, @@ -530,6 +532,7 @@ impl ChatComposer { plugins_command_enabled: false, fast_command_enabled: false, personality_command_enabled: false, + agentmemory_enabled: false, realtime_conversation_enabled: false, audio_device_selection_enabled: false, windows_degraded_sandbox_active: false, @@ -619,6 +622,10 @@ impl ChatComposer { self.personality_command_enabled = enabled; } + pub fn set_agentmemory_enabled(&mut self, enabled: bool) { + self.agentmemory_enabled = enabled; + } + pub fn set_realtime_conversation_enabled(&mut self, enabled: bool) { self.realtime_conversation_enabled = enabled; } @@ -3501,6 +3508,7 @@ impl ChatComposer { let plugins_command_enabled = self.plugins_command_enabled; let fast_command_enabled = self.fast_command_enabled; let personality_command_enabled = self.personality_command_enabled; + let agentmemory_enabled = self.agentmemory_enabled; let realtime_conversation_enabled = self.realtime_conversation_enabled; let audio_device_selection_enabled = self.audio_device_selection_enabled; let mut command_popup = CommandPopup::new( @@ -3511,6 +3519,7 @@ impl ChatComposer { plugins_command_enabled, fast_command_enabled, personality_command_enabled, + agentmemory_enabled, realtime_conversation_enabled, audio_device_selection_enabled, windows_degraded_sandbox_active: self.windows_degraded_sandbox_active, diff --git a/codex-rs/tui_app_server/src/bottom_pane/command_popup.rs b/codex-rs/tui_app_server/src/bottom_pane/command_popup.rs index 5ad3df5c2..06c65d44e 100644 --- a/codex-rs/tui_app_server/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui_app_server/src/bottom_pane/command_popup.rs @@ -44,6 +44,7 @@ pub(crate) struct CommandPopupFlags { pub(crate) realtime_conversation_enabled: bool, pub(crate) audio_device_selection_enabled: bool, pub(crate) windows_degraded_sandbox_active: bool, + pub(crate) agentmemory_enabled: bool, } impl From for slash_commands::BuiltinCommandFlags { @@ -57,6 +58,7 @@ impl From for slash_commands::BuiltinCommandFlags { realtime_conversation_enabled: value.realtime_conversation_enabled, audio_device_selection_enabled: value.audio_device_selection_enabled, allow_elevate_sandbox: value.windows_degraded_sandbox_active, + agentmemory_enabled: value.agentmemory_enabled, } } } @@ -516,6 +518,7 @@ mod tests { plugins_command_enabled: false, fast_command_enabled: false, personality_command_enabled: true, + agentmemory_enabled: false, realtime_conversation_enabled: false, audio_device_selection_enabled: false, windows_degraded_sandbox_active: false, @@ -539,6 +542,7 @@ mod tests { plugins_command_enabled: false, fast_command_enabled: false, personality_command_enabled: true, + agentmemory_enabled: false, realtime_conversation_enabled: false, audio_device_selection_enabled: false, windows_degraded_sandbox_active: false, @@ -562,6 +566,7 @@ mod tests { plugins_command_enabled: false, fast_command_enabled: false, personality_command_enabled: false, + agentmemory_enabled: false, realtime_conversation_enabled: false, audio_device_selection_enabled: false, windows_degraded_sandbox_active: false, @@ -593,6 +598,7 @@ mod tests { plugins_command_enabled: false, fast_command_enabled: false, personality_command_enabled: true, + agentmemory_enabled: false, realtime_conversation_enabled: false, audio_device_selection_enabled: false, windows_degraded_sandbox_active: false, @@ -616,6 +622,7 @@ mod tests { plugins_command_enabled: false, fast_command_enabled: false, personality_command_enabled: true, + agentmemory_enabled: false, realtime_conversation_enabled: true, audio_device_selection_enabled: false, windows_degraded_sandbox_active: false, diff --git a/codex-rs/tui_app_server/src/bottom_pane/mod.rs b/codex-rs/tui_app_server/src/bottom_pane/mod.rs index dd90bc11b..75abc117d 100644 --- a/codex-rs/tui_app_server/src/bottom_pane/mod.rs +++ b/codex-rs/tui_app_server/src/bottom_pane/mod.rs @@ -311,6 +311,11 @@ impl BottomPane { self.request_redraw(); } + pub fn set_agentmemory_enabled(&mut self, enabled: bool) { + self.composer.set_agentmemory_enabled(enabled); + self.request_redraw(); + } + pub fn set_fast_command_enabled(&mut self, enabled: bool) { self.composer.set_fast_command_enabled(enabled); self.request_redraw(); diff --git a/codex-rs/tui_app_server/src/bottom_pane/slash_commands.rs b/codex-rs/tui_app_server/src/bottom_pane/slash_commands.rs index 54b1a8cf4..8751da73c 100644 --- a/codex-rs/tui_app_server/src/bottom_pane/slash_commands.rs +++ b/codex-rs/tui_app_server/src/bottom_pane/slash_commands.rs @@ -20,6 +20,7 @@ pub(crate) struct BuiltinCommandFlags { pub(crate) realtime_conversation_enabled: bool, pub(crate) audio_device_selection_enabled: bool, pub(crate) allow_elevate_sandbox: bool, + pub(crate) agentmemory_enabled: bool, } /// Return the built-ins that should be visible/usable for the current input. @@ -37,6 +38,15 @@ pub(crate) fn builtins_for_input(flags: BuiltinCommandFlags) -> Vec<(&'static st .filter(|(_, cmd)| flags.personality_command_enabled || *cmd != SlashCommand::Personality) .filter(|(_, cmd)| flags.realtime_conversation_enabled || *cmd != SlashCommand::Realtime) .filter(|(_, cmd)| flags.audio_device_selection_enabled || *cmd != SlashCommand::Settings) + .filter(|(_, cmd)| { + flags.agentmemory_enabled + || !matches!( + *cmd, + SlashCommand::MemoryDrop + | SlashCommand::MemoryUpdate + | SlashCommand::MemoryRecall + ) + }) .collect() } @@ -71,6 +81,7 @@ mod tests { realtime_conversation_enabled: true, audio_device_selection_enabled: true, allow_elevate_sandbox: true, + agentmemory_enabled: true, } } @@ -132,4 +143,11 @@ mod tests { flags.audio_device_selection_enabled = false; assert_eq!(find_builtin_command("settings", flags), None); } + + #[test] + fn memory_recall_is_hidden_when_agentmemory_is_disabled() { + let mut flags = all_enabled_flags(); + flags.agentmemory_enabled = false; + assert_eq!(find_builtin_command("memory-recall", flags), None); + } } diff --git a/codex-rs/tui_app_server/src/chatwidget.rs b/codex-rs/tui_app_server/src/chatwidget.rs index 438f47080..7092331ad 100644 --- a/codex-rs/tui_app_server/src/chatwidget.rs +++ b/codex-rs/tui_app_server/src/chatwidget.rs @@ -4497,6 +4497,10 @@ impl ChatWidget { widget.bottom_pane.set_voice_transcription_enabled( widget.config.features.enabled(Feature::VoiceTranscription), ); + widget.bottom_pane.set_agentmemory_enabled( + widget.config.memories.backend + == codex_core::config::types::MemoryBackend::Agentmemory, + ); widget .bottom_pane .set_realtime_conversation_enabled(widget.realtime_conversation_enabled()); @@ -5049,10 +5053,13 @@ impl ChatWidget { self.clean_background_terminals(); } SlashCommand::MemoryDrop => { - self.add_app_server_stub_message("Memory maintenance"); + self.submit_op(AppCommand::memory_drop()); } SlashCommand::MemoryUpdate => { - self.add_app_server_stub_message("Memory maintenance"); + self.submit_op(AppCommand::memory_update()); + } + SlashCommand::MemoryRecall => { + self.submit_op(AppCommand::memory_recall(None)); } SlashCommand::Mcp => { self.add_mcp_output(); @@ -5222,6 +5229,16 @@ impl ChatWidget { })); self.bottom_pane.drain_pending_submission_state(); } + SlashCommand::MemoryRecall if !trimmed.is_empty() => { + let Some((prepared_args, _prepared_elements)) = self + .bottom_pane + .prepare_inline_args_submission(/*record_history*/ false) + else { + return; + }; + self.submit_op(AppCommand::memory_recall(Some(prepared_args))); + self.bottom_pane.drain_pending_submission_state(); + } SlashCommand::SandboxReadRoot if !trimmed.is_empty() => { let Some((prepared_args, _prepared_elements)) = self .bottom_pane diff --git a/codex-rs/tui_app_server/src/chatwidget/tests.rs b/codex-rs/tui_app_server/src/chatwidget/tests.rs index a74b43fa8..97998ea85 100644 --- a/codex-rs/tui_app_server/src/chatwidget/tests.rs +++ b/codex-rs/tui_app_server/src/chatwidget/tests.rs @@ -7283,23 +7283,13 @@ async fn slash_clear_is_disabled_while_task_running() { } #[tokio::test] -async fn slash_memory_drop_reports_stubbed_feature() { +async fn slash_memory_drop_submits_core_op() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; chat.dispatch_command(SlashCommand::MemoryDrop); - let event = rx.try_recv().expect("expected unsupported-feature error"); - match event { - AppEvent::InsertHistoryCell(cell) => { - let rendered = lines_to_single_string(&cell.display_lines(80)); - assert!(rendered.contains("Memory maintenance: Not available in app-server TUI yet.")); - } - other => panic!("expected InsertHistoryCell error, got {other:?}"), - } - assert!( - op_rx.try_recv().is_err(), - "expected no memory op to be sent" - ); + assert_matches!(next_submit_op(&mut op_rx), Op::DropMemories); + assert!(rx.try_recv().is_err(), "expected no stub message"); } #[tokio::test] @@ -7314,23 +7304,46 @@ async fn slash_mcp_requests_inventory_via_app_server() { } #[tokio::test] -async fn slash_memory_update_reports_stubbed_feature() { +async fn slash_memory_update_submits_core_op() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; chat.dispatch_command(SlashCommand::MemoryUpdate); - let event = rx.try_recv().expect("expected unsupported-feature error"); - match event { - AppEvent::InsertHistoryCell(cell) => { - let rendered = lines_to_single_string(&cell.display_lines(80)); - assert!(rendered.contains("Memory maintenance: Not available in app-server TUI yet.")); - } - other => panic!("expected InsertHistoryCell error, got {other:?}"), - } - assert!( - op_rx.try_recv().is_err(), - "expected no memory op to be sent" + assert_matches!(next_submit_op(&mut op_rx), Op::UpdateMemories); + assert!(rx.try_recv().is_err(), "expected no stub message"); +} + +#[tokio::test] +async fn slash_memory_recall_submits_core_op() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; + + chat.dispatch_command(SlashCommand::MemoryRecall); + + assert_matches!( + next_submit_op(&mut op_rx), + Op::RecallMemories { query: None } + ); + assert!(rx.try_recv().is_err(), "expected no stub message"); +} + +#[tokio::test] +async fn slash_memory_recall_with_inline_args_submits_query() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; + + chat.bottom_pane.set_composer_text( + "/memory-recall retrieval freshness".to_string(), + Vec::new(), + Vec::new(), + ); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + assert_matches!( + next_submit_op(&mut op_rx), + Op::RecallMemories { + query: Some(query) + } if query == "retrieval freshness" ); + assert!(rx.try_recv().is_err(), "expected no stub message"); } #[tokio::test] diff --git a/codex-rs/tui_app_server/src/slash_command.rs b/codex-rs/tui_app_server/src/slash_command.rs index 228120400..89a234737 100644 --- a/codex-rs/tui_app_server/src/slash_command.rs +++ b/codex-rs/tui_app_server/src/slash_command.rs @@ -58,11 +58,13 @@ pub enum SlashCommand { TestApproval, #[strum(serialize = "subagents")] MultiAgents, - // Debugging commands. - #[strum(serialize = "debug-m-drop")] + // Memory commands. + #[strum(serialize = "memory-drop")] MemoryDrop, - #[strum(serialize = "debug-m-update")] + #[strum(serialize = "memory-update")] MemoryUpdate, + #[strum(serialize = "memory-recall")] + MemoryRecall, } impl SlashCommand { @@ -90,8 +92,9 @@ impl SlashCommand { SlashCommand::Theme => "choose a syntax highlighting theme", SlashCommand::Ps => "list background terminals", SlashCommand::Stop => "stop all background terminals", - SlashCommand::MemoryDrop => "DO NOT USE", - SlashCommand::MemoryUpdate => "DO NOT USE", + SlashCommand::MemoryDrop => "clear the active memory store", + SlashCommand::MemoryUpdate => "sync and consolidate memories", + SlashCommand::MemoryRecall => "recall memories and inject into context", SlashCommand::Model => "choose what model and reasoning effort to use", SlashCommand::Fast => "toggle Fast mode to enable fastest inference at 2X plan usage", SlashCommand::Personality => "choose a communication style for Codex", @@ -131,6 +134,7 @@ impl SlashCommand { | SlashCommand::Plan | SlashCommand::Fast | SlashCommand::SandboxReadRoot + | SlashCommand::MemoryRecall ) } @@ -154,9 +158,7 @@ impl SlashCommand { | SlashCommand::Review | SlashCommand::Plan | SlashCommand::Clear - | SlashCommand::Logout - | SlashCommand::MemoryDrop - | SlashCommand::MemoryUpdate => false, + | SlashCommand::Logout => false, SlashCommand::Diff | SlashCommand::Copy | SlashCommand::Rename @@ -166,6 +168,9 @@ impl SlashCommand { | SlashCommand::DebugConfig | SlashCommand::Ps | SlashCommand::Stop + | SlashCommand::MemoryDrop + | SlashCommand::MemoryUpdate + | SlashCommand::MemoryRecall | SlashCommand::Mcp | SlashCommand::Apps | SlashCommand::Plugins From 953d2b70c458d0e8a978c0382cc25e70718ae96d Mon Sep 17 00:00:00 2001 From: Eric Juta Date: Sat, 28 Mar 2026 10:05:58 +0000 Subject: [PATCH 27/45] Clean app-server memory parity follow-up Apply post-clippy import ordering and remove the obsolete app-server TUI memory stub helper after wiring real memory operations through the app-server path. Co-authored-by: Codex --- codex-rs/app-server/src/codex_message_processor.rs | 12 ++++++------ codex-rs/tui_app_server/src/app_server_session.rs | 12 ++++++------ codex-rs/tui_app_server/src/chatwidget.rs | 8 +------- 3 files changed, 13 insertions(+), 19 deletions(-) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 0bc00a255..323f60fe6 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -117,12 +117,6 @@ use codex_app_server_protocol::ThreadBackgroundTerminalsCleanResponse; use codex_app_server_protocol::ThreadClosedNotification; use codex_app_server_protocol::ThreadCompactStartParams; use codex_app_server_protocol::ThreadCompactStartResponse; -use codex_app_server_protocol::ThreadMemoryDropParams; -use codex_app_server_protocol::ThreadMemoryDropResponse; -use codex_app_server_protocol::ThreadMemoryRecallParams; -use codex_app_server_protocol::ThreadMemoryRecallResponse; -use codex_app_server_protocol::ThreadMemoryUpdateParams; -use codex_app_server_protocol::ThreadMemoryUpdateResponse; use codex_app_server_protocol::ThreadDecrementElicitationParams; use codex_app_server_protocol::ThreadDecrementElicitationResponse; use codex_app_server_protocol::ThreadForkParams; @@ -134,6 +128,12 @@ use codex_app_server_protocol::ThreadListParams; use codex_app_server_protocol::ThreadListResponse; use codex_app_server_protocol::ThreadLoadedListParams; use codex_app_server_protocol::ThreadLoadedListResponse; +use codex_app_server_protocol::ThreadMemoryDropParams; +use codex_app_server_protocol::ThreadMemoryDropResponse; +use codex_app_server_protocol::ThreadMemoryRecallParams; +use codex_app_server_protocol::ThreadMemoryRecallResponse; +use codex_app_server_protocol::ThreadMemoryUpdateParams; +use codex_app_server_protocol::ThreadMemoryUpdateResponse; use codex_app_server_protocol::ThreadMetadataGitInfoUpdateParams; use codex_app_server_protocol::ThreadMetadataUpdateParams; use codex_app_server_protocol::ThreadMetadataUpdateResponse; diff --git a/codex-rs/tui_app_server/src/app_server_session.rs b/codex-rs/tui_app_server/src/app_server_session.rs index f37a029e3..3b69ee114 100644 --- a/codex-rs/tui_app_server/src/app_server_session.rs +++ b/codex-rs/tui_app_server/src/app_server_session.rs @@ -25,18 +25,18 @@ use codex_app_server_protocol::ThreadBackgroundTerminalsCleanParams; use codex_app_server_protocol::ThreadBackgroundTerminalsCleanResponse; use codex_app_server_protocol::ThreadCompactStartParams; use codex_app_server_protocol::ThreadCompactStartResponse; -use codex_app_server_protocol::ThreadMemoryDropParams; -use codex_app_server_protocol::ThreadMemoryDropResponse; -use codex_app_server_protocol::ThreadMemoryRecallParams; -use codex_app_server_protocol::ThreadMemoryRecallResponse; -use codex_app_server_protocol::ThreadMemoryUpdateParams; -use codex_app_server_protocol::ThreadMemoryUpdateResponse; use codex_app_server_protocol::ThreadForkParams; use codex_app_server_protocol::ThreadForkResponse; use codex_app_server_protocol::ThreadListParams; use codex_app_server_protocol::ThreadListResponse; use codex_app_server_protocol::ThreadLoadedListParams; use codex_app_server_protocol::ThreadLoadedListResponse; +use codex_app_server_protocol::ThreadMemoryDropParams; +use codex_app_server_protocol::ThreadMemoryDropResponse; +use codex_app_server_protocol::ThreadMemoryRecallParams; +use codex_app_server_protocol::ThreadMemoryRecallResponse; +use codex_app_server_protocol::ThreadMemoryUpdateParams; +use codex_app_server_protocol::ThreadMemoryUpdateResponse; use codex_app_server_protocol::ThreadReadParams; use codex_app_server_protocol::ThreadReadResponse; use codex_app_server_protocol::ThreadRealtimeAppendAudioParams; diff --git a/codex-rs/tui_app_server/src/chatwidget.rs b/codex-rs/tui_app_server/src/chatwidget.rs index 7092331ad..39c880dce 100644 --- a/codex-rs/tui_app_server/src/chatwidget.rs +++ b/codex-rs/tui_app_server/src/chatwidget.rs @@ -4498,8 +4498,7 @@ impl ChatWidget { widget.config.features.enabled(Feature::VoiceTranscription), ); widget.bottom_pane.set_agentmemory_enabled( - widget.config.memories.backend - == codex_core::config::types::MemoryBackend::Agentmemory, + widget.config.memories.backend == codex_core::config::types::MemoryBackend::Agentmemory, ); widget .bottom_pane @@ -9807,11 +9806,6 @@ impl ChatWidget { self.request_redraw(); } - fn add_app_server_stub_message(&mut self, feature: &str) { - warn!(feature, "stubbed unsupported app-server TUI feature"); - self.add_error_message(format!("{feature}: {APP_SERVER_TUI_STUB_MESSAGE}")); - } - fn rename_confirmation_cell(name: &str, thread_id: Option) -> PlainHistoryCell { let resume_cmd = codex_core::util::resume_command(Some(name), thread_id) .unwrap_or_else(|| format!("codex resume {name}")); From 41ab03969bb3236f375ffe6ab47b6313ee16f923 Mon Sep 17 00:00:00 2001 From: Eric Juta Date: Sun, 29 Mar 2026 21:49:26 +0100 Subject: [PATCH 28/45] Surface app-server TUI thread op failures Co-authored-by: Codex --- codex-rs/tui_app_server/src/app.rs | 37 +++++++++++++++++-- .../tui_app_server/src/chatwidget/tests.rs | 19 +++++----- 2 files changed, 44 insertions(+), 12 deletions(-) diff --git a/codex-rs/tui_app_server/src/app.rs b/codex-rs/tui_app_server/src/app.rs index e6433c823..37b2fa9c0 100644 --- a/codex-rs/tui_app_server/src/app.rs +++ b/codex-rs/tui_app_server/src/app.rs @@ -1030,6 +1030,10 @@ fn active_turn_missing_steer_error(error: &TypedRequestError) -> bool { source.message == "no active turn to steer" } +fn thread_op_error_message(target: &str, op: &AppCommand, err: &color_eyre::Report) -> String { + format!("Failed to run {} for {target}: {err}", op.kind()) +} + impl App { pub fn chatwidget_init_for_forked_or_resumed_thread( &self, @@ -3838,11 +3842,24 @@ impl App { return Ok(AppRunControl::Exit(ExitReason::Fatal(message))); } AppEvent::CodexOp(op) => { - self.submit_active_thread_op(app_server, op.into()).await?; + let op: AppCommand = op.into(); + if let Err(err) = self.submit_active_thread_op(app_server, op.clone()).await { + let message = thread_op_error_message("current thread", &op, &err); + tracing::warn!(error = %err, op = op.kind(), "{message}"); + self.chat_widget.add_error_message(message); + } } AppEvent::SubmitThreadOp { thread_id, op } => { - self.submit_thread_op(app_server, thread_id, op.into()) - .await?; + let op: AppCommand = op.into(); + if let Err(err) = self + .submit_thread_op(app_server, thread_id, op.clone()) + .await + { + let target = format!("thread {thread_id}"); + let message = thread_op_error_message(&target, &op, &err); + tracing::warn!(error = %err, op = op.kind(), thread_id = %thread_id, "{message}"); + self.chat_widget.add_error_message(message); + } } AppEvent::ThreadHistoryEntryResponse { thread_id, event } => { self.enqueue_thread_history_entry_response(thread_id, event) @@ -5916,6 +5933,20 @@ mod tests { ); } + #[test] + fn thread_op_error_message_mentions_op_kind_and_target() { + let message = thread_op_error_message( + "current thread", + &AppCommand::memory_recall(Some("retrieval freshness".to_string())), + &color_eyre::eyre::eyre!("connection reset by peer"), + ); + + assert_eq!( + message, + "Failed to run recall_memories for current thread: connection reset by peer" + ); + } + #[tokio::test] async fn enqueue_primary_thread_session_replays_buffered_approval_after_attach() -> Result<()> { let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; diff --git a/codex-rs/tui_app_server/src/chatwidget/tests.rs b/codex-rs/tui_app_server/src/chatwidget/tests.rs index 97998ea85..40f5ad970 100644 --- a/codex-rs/tui_app_server/src/chatwidget/tests.rs +++ b/codex-rs/tui_app_server/src/chatwidget/tests.rs @@ -7285,10 +7285,11 @@ async fn slash_clear_is_disabled_while_task_running() { #[tokio::test] async fn slash_memory_drop_submits_core_op() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; + chat.bottom_pane.set_agentmemory_enabled(true); chat.dispatch_command(SlashCommand::MemoryDrop); - assert_matches!(next_submit_op(&mut op_rx), Op::DropMemories); + assert_matches!(op_rx.try_recv(), Ok(Op::DropMemories)); assert!(rx.try_recv().is_err(), "expected no stub message"); } @@ -7306,29 +7307,29 @@ async fn slash_mcp_requests_inventory_via_app_server() { #[tokio::test] async fn slash_memory_update_submits_core_op() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; + chat.bottom_pane.set_agentmemory_enabled(true); chat.dispatch_command(SlashCommand::MemoryUpdate); - assert_matches!(next_submit_op(&mut op_rx), Op::UpdateMemories); + assert_matches!(op_rx.try_recv(), Ok(Op::UpdateMemories)); assert!(rx.try_recv().is_err(), "expected no stub message"); } #[tokio::test] async fn slash_memory_recall_submits_core_op() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; + chat.bottom_pane.set_agentmemory_enabled(true); chat.dispatch_command(SlashCommand::MemoryRecall); - assert_matches!( - next_submit_op(&mut op_rx), - Op::RecallMemories { query: None } - ); + assert_matches!(op_rx.try_recv(), Ok(Op::RecallMemories { query: None })); assert!(rx.try_recv().is_err(), "expected no stub message"); } #[tokio::test] async fn slash_memory_recall_with_inline_args_submits_query() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; + chat.bottom_pane.set_agentmemory_enabled(true); chat.bottom_pane.set_composer_text( "/memory-recall retrieval freshness".to_string(), @@ -7338,10 +7339,10 @@ async fn slash_memory_recall_with_inline_args_submits_query() { chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); assert_matches!( - next_submit_op(&mut op_rx), - Op::RecallMemories { + op_rx.try_recv(), + Ok(Op::RecallMemories { query: Some(query) - } if query == "retrieval freshness" + }) if query == "retrieval freshness" ); assert!(rx.try_recv().is_err(), "expected no stub message"); } From fbad88c53fc892da6ddc5d7ec6bea1b6423a4698 Mon Sep 17 00:00:00 2001 From: Eric Juta Date: Sun, 29 Mar 2026 22:13:05 +0100 Subject: [PATCH 29/45] Document repo-local hooks configuration Co-authored-by: Codex --- codex-rs/hooks/README.md | 157 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 codex-rs/hooks/README.md diff --git a/codex-rs/hooks/README.md b/codex-rs/hooks/README.md new file mode 100644 index 000000000..395a60774 --- /dev/null +++ b/codex-rs/hooks/README.md @@ -0,0 +1,157 @@ +# Codex Hooks + +Codex supports lifecycle hooks configured through `hooks.json` files discovered +from the active config layers. + +For repo-local usage, put the config in: + +```text +/.codex/hooks.json +``` + +and store any helper scripts beside it, for example: + +```text +/.codex/hooks/allium-check.mjs +``` + +This is the Codex equivalent of Claude-style repo hooks such as +`.claude/hooks/...`. + +## Scope And Discovery + +Hook configs are discovered from config folders in precedence order. + +Common locations: + +- User/global: `~/.codex/hooks.json` +- Project/repo: `/.codex/hooks.json` +- System config folder: `hooks.json` beside the system config layer + +Project-level hooks are supported because project config layers resolve to the +repo `.codex/` directory. + +## File Format + +`hooks.json` uses this shape: + +```json +{ + "hooks": { + "EventName": [ + { + "matcher": "optional-regex-or-*", + "hooks": [ + { + "type": "command", + "command": "node ./.codex/hooks/example.mjs", + "timeout": 60, + "statusMessage": "Running repo hook" + } + ] + } + ] + } +} +``` + +## Supported Events + +- `SessionStart` +- `UserPromptSubmit` +- `PreToolUse` +- `PostToolUse` +- `PostToolUseFailure` +- `PreCompact` +- `SubagentStart` +- `SubagentStop` +- `Notification` +- `TaskCompleted` +- `Stop` +- `SessionEnd` + +## Supported Hook Types + +Currently supported: + +- `command` + +Recognized but currently skipped: + +- `prompt` +- `agent` + +Also note: + +- `async: true` is parsed but not supported yet, so async hooks are skipped. +- On Windows, `hooks.json` lifecycle hooks are currently disabled. + +## Matcher Behavior + +`matcher` is mainly useful for event families that naturally carry a target, +such as tool names or session-start source names. + +Examples: + +- `PreToolUse` / `PostToolUse` / `PostToolUseFailure`: match against the tool + name, such as `Bash`, `Edit`, or `Write` +- `SessionStart`: match against startup source names +- `UserPromptSubmit` and `Stop`: matcher is ignored + +`*` means match-all. + +## Repo-Local Example + +Run an Allium validation script after edit-like tools: + +```json +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "Edit|Write|apply_patch", + "hooks": [ + { + "type": "command", + "command": "node ./.codex/hooks/allium-check.mjs", + "timeout": 60, + "statusMessage": "Running Allium checks" + } + ] + } + ] + } +} +``` + +## Another Example + +Run a lightweight repo bootstrap check at session start: + +```json +{ + "hooks": { + "SessionStart": [ + { + "matcher": "startup|resume", + "hooks": [ + { + "type": "command", + "command": "./.codex/hooks/session-start-check.sh", + "timeout": 30, + "statusMessage": "Checking repo environment" + } + ] + } + ] + } +} +``` + +## Notes + +- Hook discovery is config-layer based, not `AGENTS.md` based. +- `AGENTS.md` is for instructions; `hooks.json` is for executable lifecycle + hooks. +- If multiple config layers define hooks, lower-precedence layers are loaded + first and higher-precedence layers are appended later. From 412c5591445c56b6ab7c9f81e5c2b038486cb573 Mon Sep 17 00:00:00 2001 From: Eric Juta Date: Sun, 29 Mar 2026 22:13:41 +0100 Subject: [PATCH 30/45] Fix argument comment lint in codex core Co-authored-by: Codex --- codex-rs/core/src/codex.rs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 36ff5276f..d8dd010d3 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1298,7 +1298,7 @@ impl Session { match adapter.end_session(session_id.as_str()).await { Ok(()) => { let mut state = self.state.lock().await; - state.set_agentmemory_session_ended(true); + state.set_agentmemory_session_ended(/*ended*/ true); } Err(err) => { warn!("Agentmemory session end failed for {session_id}: {err}"); @@ -3565,7 +3565,10 @@ impl Session { let adapter = crate::agentmemory::AgentmemoryAdapter::new(); // Provide a default explicit token budget for the startup query context adapter - .build_startup_developer_instructions(&turn_context.config.codex_home, 2000) + .build_startup_developer_instructions( + &turn_context.config.codex_home, + /*token_budget*/ 2000, + ) .await } crate::config::types::MemoryBackend::Native => { @@ -5244,7 +5247,12 @@ mod handlers { let session_id = sess.conversation_id.to_string(); match adapter - .recall_context(&session_id, config.cwd.as_ref(), query.as_deref(), 2000) + .recall_context( + &session_id, + config.cwd.as_ref(), + query.as_deref(), + /*token_budget*/ 2000, + ) .await { Ok(context) if !context.trim().is_empty() => { From 3952eb4e77c10e0c034a76855f7bc217cb2f57a3 Mon Sep 17 00:00:00 2001 From: Eric Juta Date: Sun, 29 Mar 2026 22:18:28 +0100 Subject: [PATCH 31/45] Add private fork GitHub Actions spec Co-authored-by: Codex --- .../docs/github_actions_private_fork_spec.md | 114 ++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 codex-rs/docs/github_actions_private_fork_spec.md diff --git a/codex-rs/docs/github_actions_private_fork_spec.md b/codex-rs/docs/github_actions_private_fork_spec.md new file mode 100644 index 000000000..6a5437699 --- /dev/null +++ b/codex-rs/docs/github_actions_private_fork_spec.md @@ -0,0 +1,114 @@ +# GitHub Actions Private Fork Spec + +## Goal + +Define which GitHub Actions workflows are worth keeping in a private personal +fork of this repository and which should usually be disabled or removed. + +## Non-Goals + +- changing upstream CI policy for the canonical repo +- designing a public release process +- replacing local development checks with hosted CI + +## Workflow Inventory + +Observed workflows fall into these buckets: + +- product CI +- dependency and security checks +- release automation +- contributor governance +- issue and PR triage +- vendored upstream CI + +Examples of likely keep candidates: + +- rust-ci.yml +- bazel.yml +- cargo-deny.yml +- codex-rs/.github/workflows/cargo-audit.yml +- codespell.yml +- sdk.yml + +Examples of workflows that are usually unnecessary in a private fork: + +- cla.yml +- close-stale-contributor-prs.yml +- issue-deduplicator.yml +- issue-labeler.yml +- blob-size-policy.yml +- rust-release-argument-comment-lint.yml +- rust-release-prepare.yml +- rust-release-windows.yml +- rust-release-zsh.yml +- rust-release.yml +- rusty-v8-release.yml +- v8-canary.yml +- codex-rs/vendor/bubblewrap/.github/workflows/check.yml + +## Desired Outcomes + +1. Keep enough CI to catch regressions in code paths the fork owner actually + uses. +2. Avoid wasting Actions minutes on public-maintainer workflows that have no + value in a private repo. +3. Keep the retained workflow set easy to understand and maintain. + +## Classification + +### Keep + +- rust-ci.yml when Cargo and Rust are the main development path +- codex-rs/.github/workflows/cargo-audit.yml for vulnerability visibility +- cargo-deny.yml for dependency and license policy signal + +### Optional + +- bazel.yml if Bazel is the real source of truth for the fork +- codespell.yml if cheap docs hygiene is still useful +- sdk.yml if the SDK packages are actively used +- ci.yml if the root JS/npm package and docs packaging flow matter to the fork + +### Usually Remove Or Disable + +- contributor governance workflows +- issue triage workflows +- PR governance workflows +- release and publishing workflows +- vendored upstream workflows + +## Recommended Baselines + +### Minimal Cargo-First Fork + +- keep rust-ci.yml +- keep codex-rs/.github/workflows/cargo-audit.yml +- keep cargo-deny.yml +- optionally keep codespell.yml +- disable or remove the rest + +### Bazel-First Fork + +- keep bazel.yml +- keep codex-rs/.github/workflows/cargo-audit.yml +- keep cargo-deny.yml +- optionally keep rust-ci.yml and codespell.yml + +## Acceptance Criteria + +1. At least one real product CI lane remains enabled. +2. Security and dependency visibility remains available through cargo-audit or + an equivalent workflow. +3. CLA, stale PR, issue triage, and release workflows no longer consume Actions + runs unless the fork explicitly needs them. +4. The retained workflow set is documented in terms of why each workflow still + exists. + +## Recommendation + +For this repository as a private personal fork, the default recommendation is +to keep rust-ci.yml, codex-rs/.github/workflows/cargo-audit.yml, and +cargo-deny.yml; optionally keep bazel.yml and codespell.yml; and disable or +remove the contributor, release, issue-triage, and vendored-upstream +workflows. From 4b42fc0216c1672f30fedce7a36fbe575ff57990 Mon Sep 17 00:00:00 2001 From: Eric Juta Date: Sun, 29 Mar 2026 22:24:28 +0100 Subject: [PATCH 32/45] Trim GitHub Actions for private fork baseline Co-authored-by: Codex --- .github/workflows/bazel.yml | 231 ------ .github/workflows/blob-size-policy.yml | 32 - .github/workflows/ci.yml | 66 -- .github/workflows/cla.yml | 49 -- .../workflows/close-stale-contributor-prs.yml | 107 --- .github/workflows/codespell.yml | 27 - .github/workflows/issue-deduplicator.yml | 402 ---------- .github/workflows/issue-labeler.yml | 133 ---- .../rust-release-argument-comment-lint.yml | 103 --- .github/workflows/rust-release-prepare.yml | 53 -- .github/workflows/rust-release-windows.yml | 264 ------- .github/workflows/rust-release-zsh.yml | 95 --- .github/workflows/rust-release.yml | 722 ------------------ .github/workflows/rusty-v8-release.yml | 188 ----- .github/workflows/sdk.yml | 52 -- .github/workflows/v8-canary.yml | 132 ---- .../bubblewrap/.github/workflows/check.yml | 105 --- scripts/stage_npm_packages.py | 16 +- third_party/v8/README.md | 4 +- 19 files changed, 14 insertions(+), 2767 deletions(-) delete mode 100644 .github/workflows/bazel.yml delete mode 100644 .github/workflows/blob-size-policy.yml delete mode 100644 .github/workflows/ci.yml delete mode 100644 .github/workflows/cla.yml delete mode 100644 .github/workflows/close-stale-contributor-prs.yml delete mode 100644 .github/workflows/codespell.yml delete mode 100644 .github/workflows/issue-deduplicator.yml delete mode 100644 .github/workflows/issue-labeler.yml delete mode 100644 .github/workflows/rust-release-argument-comment-lint.yml delete mode 100644 .github/workflows/rust-release-prepare.yml delete mode 100644 .github/workflows/rust-release-windows.yml delete mode 100644 .github/workflows/rust-release-zsh.yml delete mode 100644 .github/workflows/rust-release.yml delete mode 100644 .github/workflows/rusty-v8-release.yml delete mode 100644 .github/workflows/sdk.yml delete mode 100644 .github/workflows/v8-canary.yml delete mode 100644 codex-rs/vendor/bubblewrap/.github/workflows/check.yml diff --git a/.github/workflows/bazel.yml b/.github/workflows/bazel.yml deleted file mode 100644 index b2ef107ca..000000000 --- a/.github/workflows/bazel.yml +++ /dev/null @@ -1,231 +0,0 @@ -name: Bazel (experimental) - -# Note this workflow was originally derived from: -# https://github.com/cerisier/toolchains_llvm_bootstrapped/blob/main/.github/workflows/ci.yaml - -on: - pull_request: {} - push: - branches: - - main - workflow_dispatch: - -concurrency: - # Cancel previous actions from the same PR or branch except 'main' branch. - # See https://docs.github.com/en/actions/using-jobs/using-concurrency and https://docs.github.com/en/actions/learn-github-actions/contexts for more info. - group: concurrency-group::${{ github.workflow }}::${{ github.event.pull_request.number > 0 && format('pr-{0}', github.event.pull_request.number) || github.ref_name }}${{ github.ref_name == 'main' && format('::{0}', github.run_id) || ''}} - cancel-in-progress: ${{ github.ref_name != 'main' }} -jobs: - test: - strategy: - fail-fast: false - matrix: - include: - # macOS - - os: macos-15-xlarge - target: aarch64-apple-darwin - - os: macos-15-xlarge - target: x86_64-apple-darwin - - # Linux - - os: ubuntu-24.04 - target: x86_64-unknown-linux-gnu - - os: ubuntu-24.04 - target: x86_64-unknown-linux-musl - # 2026-02-27 Bazel tests have been flaky on arm in CI. - # Disable until we can investigate and stabilize them. - # - os: ubuntu-24.04-arm - # target: aarch64-unknown-linux-musl - # - os: ubuntu-24.04-arm - # target: aarch64-unknown-linux-gnu - - # TODO: Enable Windows once we fix the toolchain issues there. - #- os: windows-latest - # target: x86_64-pc-windows-gnullvm - runs-on: ${{ matrix.os }} - - # Configure a human readable name for each job - name: Local Bazel build on ${{ matrix.os }} for ${{ matrix.target }} - - steps: - - uses: actions/checkout@v6 - - - name: Set up Node.js for js_repl tests - uses: actions/setup-node@v6 - with: - node-version-file: codex-rs/node-version.txt - - # Some integration tests rely on DotSlash being installed. - # See https://github.com/openai/codex/pull/7617. - - name: Install DotSlash - uses: facebook/install-dotslash@v2 - - - name: Make DotSlash available in PATH (Unix) - if: runner.os != 'Windows' - run: cp "$(which dotslash)" /usr/local/bin - - - name: Make DotSlash available in PATH (Windows) - if: runner.os == 'Windows' - shell: pwsh - run: Copy-Item (Get-Command dotslash).Source -Destination "$env:LOCALAPPDATA\Microsoft\WindowsApps\dotslash.exe" - - # Install Bazel via Bazelisk - - name: Set up Bazel - uses: bazelbuild/setup-bazelisk@v3 - - - name: Check MODULE.bazel.lock is up to date - if: matrix.os == 'ubuntu-24.04' && matrix.target == 'x86_64-unknown-linux-gnu' - shell: bash - run: ./scripts/check-module-bazel-lock.sh - - # TODO(mbolin): Bring this back once we have caching working. Currently, - # we never seem to get a cache hit but we still end up paying the cost of - # uploading at the end of the build, which takes over a minute! - # - # Cache build and external artifacts so that the next ci build is incremental. - # Because github action caches cannot be updated after a build, we need to - # store the contents of each build in a unique cache key, then fall back to loading - # it on the next ci run. We use hashFiles(...) in the key and restore-keys- with - # the prefix to load the most recent cache for the branch on a cache miss. You - # should customize the contents of hashFiles to capture any bazel input sources, - # although this doesn't need to be perfect. If none of the input sources change - # then a cache hit will load an existing cache and bazel won't have to do any work. - # In the case of a cache miss, you want the fallback cache to contain most of the - # previously built artifacts to minimize build time. The more precise you are with - # hashFiles sources the less work bazel will have to do. - # - name: Mount bazel caches - # uses: actions/cache@v5 - # with: - # path: | - # ~/.cache/bazel-repo-cache - # ~/.cache/bazel-repo-contents-cache - # key: bazel-cache-${{ matrix.os }}-${{ hashFiles('**/BUILD.bazel', '**/*.bzl', 'MODULE.bazel') }} - # restore-keys: | - # bazel-cache-${{ matrix.os }} - - - name: Configure Bazel startup args (Windows) - if: runner.os == 'Windows' - shell: pwsh - run: | - # Use a very short path to reduce argv/path length issues. - "BAZEL_STARTUP_ARGS=--output_user_root=C:\" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - - - name: bazel test //... - env: - BUILDBUDDY_API_KEY: ${{ secrets.BUILDBUDDY_API_KEY }} - shell: bash - run: | - set -o pipefail - - bazel_console_log="$(mktemp)" - - print_failed_bazel_test_logs() { - local console_log="$1" - local testlogs_dir - - testlogs_dir="$(bazel $BAZEL_STARTUP_ARGS info bazel-testlogs 2>/dev/null || echo bazel-testlogs)" - - local failed_targets=() - while IFS= read -r target; do - failed_targets+=("$target") - done < <( - grep -E '^FAIL: //' "$console_log" \ - | sed -E 's#^FAIL: (//[^ ]+).*#\1#' \ - | sort -u - ) - - if [[ ${#failed_targets[@]} -eq 0 ]]; then - echo "No failed Bazel test targets were found in console output." - return - fi - - for target in "${failed_targets[@]}"; do - local rel_path="${target#//}" - rel_path="${rel_path/:/\/}" - local test_log="${testlogs_dir}/${rel_path}/test.log" - - echo "::group::Bazel test log tail for ${target}" - if [[ -f "$test_log" ]]; then - tail -n 200 "$test_log" - else - echo "Missing test log: $test_log" - fi - echo "::endgroup::" - done - } - - bazel_args=( - test - --test_verbose_timeout_warnings - --build_metadata=REPO_URL=https://github.com/openai/codex.git - --build_metadata=COMMIT_SHA=$(git rev-parse HEAD) - --build_metadata=ROLE=CI - --build_metadata=VISIBILITY=PUBLIC - ) - - bazel_targets=( - //... - # Keep V8 out of the ordinary Bazel CI path. Only the dedicated - # canary and release workflows should build `third_party/v8`. - -//third_party/v8:all - ) - - if [[ "${RUNNER_OS:-}" != "Windows" ]]; then - # Bazel test sandboxes on macOS may resolve an older Homebrew `node` - # before the `actions/setup-node` runtime on PATH. - node_bin="$(which node)" - bazel_args+=("--test_env=CODEX_JS_REPL_NODE_PATH=${node_bin}") - fi - - if [[ -n "${BUILDBUDDY_API_KEY:-}" ]]; then - echo "BuildBuddy API key is available; using remote Bazel configuration." - # Work around Bazel 9 remote repo contents cache / overlay materialization failures - # seen in CI (for example "is not a symlink" or permission errors while - # materializing external repos such as rules_perl). We still use BuildBuddy for - # remote execution/cache; this only disables the startup-level repo contents cache. - set +e - bazel $BAZEL_STARTUP_ARGS \ - --noexperimental_remote_repo_contents_cache \ - --bazelrc=.github/workflows/ci.bazelrc \ - "${bazel_args[@]}" \ - "--remote_header=x-buildbuddy-api-key=$BUILDBUDDY_API_KEY" \ - -- \ - "${bazel_targets[@]}" \ - 2>&1 | tee "$bazel_console_log" - bazel_status=${PIPESTATUS[0]} - set -e - else - echo "BuildBuddy API key is not available; using local Bazel configuration." - # Keep fork/community PRs on Bazel but disable remote services that are - # configured in .bazelrc and require auth. - # - # Flag docs: - # - Command-line reference: https://bazel.build/reference/command-line-reference - # - Remote caching overview: https://bazel.build/remote/caching - # - Remote execution overview: https://bazel.build/remote/rbe - # - Build Event Protocol overview: https://bazel.build/remote/bep - # - # --noexperimental_remote_repo_contents_cache: - # disable remote repo contents cache enabled in .bazelrc startup options. - # https://bazel.build/reference/command-line-reference#startup_options-flag--experimental_remote_repo_contents_cache - # --remote_cache= and --remote_executor=: - # clear remote cache/execution endpoints configured in .bazelrc. - # https://bazel.build/reference/command-line-reference#common_options-flag--remote_cache - # https://bazel.build/reference/command-line-reference#common_options-flag--remote_executor - set +e - bazel $BAZEL_STARTUP_ARGS \ - --noexperimental_remote_repo_contents_cache \ - "${bazel_args[@]}" \ - --remote_cache= \ - --remote_executor= \ - -- \ - "${bazel_targets[@]}" \ - 2>&1 | tee "$bazel_console_log" - bazel_status=${PIPESTATUS[0]} - set -e - fi - - if [[ ${bazel_status:-0} -ne 0 ]]; then - print_failed_bazel_test_logs "$bazel_console_log" - exit "$bazel_status" - fi diff --git a/.github/workflows/blob-size-policy.yml b/.github/workflows/blob-size-policy.yml deleted file mode 100644 index bce6e4979..000000000 --- a/.github/workflows/blob-size-policy.yml +++ /dev/null @@ -1,32 +0,0 @@ -name: blob-size-policy - -on: - pull_request: {} - -jobs: - check: - name: Blob size policy - runs-on: ubuntu-24.04 - steps: - - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - - name: Determine PR comparison range - id: range - shell: bash - run: | - set -euo pipefail - echo "base=$(git rev-parse HEAD^1)" >> "$GITHUB_OUTPUT" - echo "head=$(git rev-parse HEAD^2)" >> "$GITHUB_OUTPUT" - - - name: Check changed blob sizes - env: - BASE_SHA: ${{ steps.range.outputs.base }} - HEAD_SHA: ${{ steps.range.outputs.head }} - run: | - python3 scripts/check_blob_size.py \ - --base "$BASE_SHA" \ - --head "$HEAD_SHA" \ - --max-bytes 512000 \ - --allowlist .github/blob-size-allowlist.txt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index bbd2df27c..000000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,66 +0,0 @@ -name: ci - -on: - pull_request: {} - push: { branches: [main] } - -jobs: - build-test: - runs-on: ubuntu-latest - timeout-minutes: 10 - env: - NODE_OPTIONS: --max-old-space-size=4096 - steps: - - name: Checkout repository - uses: actions/checkout@v6 - - - name: Setup pnpm - uses: pnpm/action-setup@v5 - with: - run_install: false - - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: 22 - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - # stage_npm_packages.py requires DotSlash when staging releases. - - uses: facebook/install-dotslash@v2 - - - name: Stage npm package - id: stage_npm_package - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - # Use a rust-release version that includes all native binaries. - CODEX_VERSION=0.115.0 - OUTPUT_DIR="${RUNNER_TEMP}" - python3 ./scripts/stage_npm_packages.py \ - --release-version "$CODEX_VERSION" \ - --package codex \ - --output-dir "$OUTPUT_DIR" - PACK_OUTPUT="${OUTPUT_DIR}/codex-npm-${CODEX_VERSION}.tgz" - echo "pack_output=$PACK_OUTPUT" >> "$GITHUB_OUTPUT" - - - name: Upload staged npm package artifact - uses: actions/upload-artifact@v7 - with: - name: codex-npm-staging - path: ${{ steps.stage_npm_package.outputs.pack_output }} - - - name: Ensure root README.md contains only ASCII and certain Unicode code points - run: ./scripts/asciicheck.py README.md - - name: Check root README ToC - run: python3 scripts/readme_toc.py README.md - - - name: Ensure codex-cli/README.md contains only ASCII and certain Unicode code points - run: ./scripts/asciicheck.py codex-cli/README.md - - name: Check codex-cli/README ToC - run: python3 scripts/readme_toc.py codex-cli/README.md - - - name: Prettier (run `pnpm run format:fix` to fix) - run: pnpm run format diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml deleted file mode 100644 index bab34d036..000000000 --- a/.github/workflows/cla.yml +++ /dev/null @@ -1,49 +0,0 @@ -name: CLA Assistant -on: - issue_comment: - types: [created] - pull_request_target: - types: [opened, closed, synchronize] - -permissions: - actions: write - contents: write - pull-requests: write - statuses: write - -jobs: - cla: - # Only run the CLA assistant for the canonical openai repo so forks are not blocked - # and contributors who signed previously do not receive duplicate CLA notifications. - if: ${{ github.repository_owner == 'openai' }} - runs-on: ubuntu-latest - steps: - - uses: contributor-assistant/github-action@v2.6.1 - # Run on close only if the PR was merged. This will lock the PR to preserve - # the CLA agreement. We don't want to lock PRs that have been closed without - # merging because the contributor may want to respond with additional comments. - # This action has a "lock-pullrequest-aftermerge" option that can be set to false, - # but that would unconditionally skip locking even in cases where the PR was merged. - if: | - ( - github.event_name == 'pull_request_target' && - ( - github.event.action == 'opened' || - github.event.action == 'synchronize' || - (github.event.action == 'closed' && github.event.pull_request.merged == true) - ) - ) || - ( - github.event_name == 'issue_comment' && - ( - github.event.comment.body == 'recheck' || - github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA' - ) - ) - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - path-to-document: https://github.com/openai/codex/blob/main/docs/CLA.md - path-to-signatures: signatures/cla.json - branch: cla-signatures - allowlist: codex,dependabot,dependabot[bot],github-actions[bot] diff --git a/.github/workflows/close-stale-contributor-prs.yml b/.github/workflows/close-stale-contributor-prs.yml deleted file mode 100644 index 43e699288..000000000 --- a/.github/workflows/close-stale-contributor-prs.yml +++ /dev/null @@ -1,107 +0,0 @@ -name: Close stale contributor PRs - -on: - workflow_dispatch: - schedule: - - cron: "0 6 * * *" - -permissions: - contents: read - issues: write - pull-requests: write - -jobs: - close-stale-contributor-prs: - # Prevent scheduled runs on forks - if: github.repository == 'openai/codex' - runs-on: ubuntu-latest - steps: - - name: Close inactive PRs from contributors - uses: actions/github-script@v8 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const DAYS_INACTIVE = 14; - const cutoff = new Date(Date.now() - DAYS_INACTIVE * 24 * 60 * 60 * 1000); - const { owner, repo } = context.repo; - const dryRun = false; - const stalePrs = []; - - core.info(`Dry run mode: ${dryRun}`); - - const prs = await github.paginate(github.rest.pulls.list, { - owner, - repo, - state: "open", - per_page: 100, - sort: "updated", - direction: "asc", - }); - - for (const pr of prs) { - const lastUpdated = new Date(pr.updated_at); - if (lastUpdated > cutoff) { - core.info(`PR ${pr.number} is fresh`); - continue; - } - - if (!pr.user || pr.user.type !== "User") { - core.info(`PR ${pr.number} wasn't created by a user`); - continue; - } - - let permission; - try { - const permissionResponse = await github.rest.repos.getCollaboratorPermissionLevel({ - owner, - repo, - username: pr.user.login, - }); - permission = permissionResponse.data.permission; - } catch (error) { - if (error.status === 404) { - core.info(`Author ${pr.user.login} is not a collaborator; skipping #${pr.number}`); - continue; - } - throw error; - } - - const hasContributorAccess = ["admin", "maintain", "write"].includes(permission); - if (!hasContributorAccess) { - core.info(`Author ${pr.user.login} has ${permission} access; skipping #${pr.number}`); - continue; - } - - stalePrs.push(pr); - } - - if (!stalePrs.length) { - core.info("No stale contributor pull requests found."); - return; - } - - for (const pr of stalePrs) { - const issue_number = pr.number; - const closeComment = `Closing this pull request because it has had no updates for more than ${DAYS_INACTIVE} days. If you plan to continue working on it, feel free to reopen or open a new PR.`; - - if (dryRun) { - core.info(`[dry-run] Would close contributor PR #${issue_number} from ${pr.user.login}`); - continue; - } - - await github.rest.issues.createComment({ - owner, - repo, - issue_number, - body: closeComment, - }); - - await github.rest.pulls.update({ - owner, - repo, - pull_number: issue_number, - state: "closed", - }); - - core.info(`Closed contributor PR #${issue_number} from ${pr.user.login}`); - } diff --git a/.github/workflows/codespell.yml b/.github/workflows/codespell.yml deleted file mode 100644 index bbbb06d06..000000000 --- a/.github/workflows/codespell.yml +++ /dev/null @@ -1,27 +0,0 @@ -# Codespell configuration is within .codespellrc ---- -name: Codespell - -on: - push: - branches: [main] - pull_request: - branches: [main] - -permissions: - contents: read - -jobs: - codespell: - name: Check for spelling errors - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@v6 - - name: Annotate locations with typos - uses: codespell-project/codespell-problem-matcher@b80729f885d32f78a716c2f107b4db1025001c42 # v1 - - name: Codespell - uses: codespell-project/actions-codespell@8f01853be192eb0f849a5c7d721450e7a467c579 # v2.2 - with: - ignore_words_file: .codespellignore diff --git a/.github/workflows/issue-deduplicator.yml b/.github/workflows/issue-deduplicator.yml deleted file mode 100644 index 6f4df87f4..000000000 --- a/.github/workflows/issue-deduplicator.yml +++ /dev/null @@ -1,402 +0,0 @@ -name: Issue Deduplicator - -on: - issues: - types: - - opened - - labeled - -jobs: - gather-duplicates-all: - name: Identify potential duplicates (all issues) - # Prevent runs on forks (requires OpenAI API key, wastes Actions minutes) - if: github.repository == 'openai/codex' && (github.event.action == 'opened' || (github.event.action == 'labeled' && github.event.label.name == 'codex-deduplicate')) - runs-on: ubuntu-latest - permissions: - contents: read - outputs: - issues_json: ${{ steps.normalize-all.outputs.issues_json }} - reason: ${{ steps.normalize-all.outputs.reason }} - has_matches: ${{ steps.normalize-all.outputs.has_matches }} - steps: - - uses: actions/checkout@v6 - - - name: Prepare Codex inputs - env: - GH_TOKEN: ${{ github.token }} - REPO: ${{ github.repository }} - ISSUE_NUMBER: ${{ github.event.issue.number }} - run: | - set -eo pipefail - - CURRENT_ISSUE_FILE=codex-current-issue.json - EXISTING_ALL_FILE=codex-existing-issues-all.json - - gh issue list --repo "$REPO" \ - --json number,title,body,createdAt,updatedAt,state,labels \ - --limit 1000 \ - --state all \ - --search "sort:created-desc" \ - | jq '[.[] | { - number, - title, - body: ((.body // "")[0:4000]), - createdAt, - updatedAt, - state, - labels: ((.labels // []) | map(.name)) - }]' \ - > "$EXISTING_ALL_FILE" - - gh issue view "$ISSUE_NUMBER" \ - --repo "$REPO" \ - --json number,title,body \ - | jq '{number, title, body: ((.body // "")[0:4000])}' \ - > "$CURRENT_ISSUE_FILE" - - echo "Prepared duplicate detection input files." - echo "all_issue_count=$(jq 'length' "$EXISTING_ALL_FILE")" - - # Prompt instructions are intentionally inline in this workflow. The old - # .github/prompts/issue-deduplicator.txt file is obsolete and removed. - - id: codex-all - name: Find duplicates (pass 1, all issues) - uses: openai/codex-action@main - with: - openai-api-key: ${{ secrets.CODEX_OPENAI_API_KEY }} - allow-users: "*" - prompt: | - You are an assistant that triages new GitHub issues by identifying potential duplicates. - - You will receive the following JSON files located in the current working directory: - - `codex-current-issue.json`: JSON object describing the newly created issue (fields: number, title, body). - - `codex-existing-issues-all.json`: JSON array of recent issues with states, timestamps, and labels. - - Instructions: - - Compare the current issue against the existing issues to find up to five that appear to describe the same underlying problem or request. - - Prioritize concrete overlap in symptoms, reproduction details, error signatures, and user intent. - - Prefer active unresolved issues when confidence is similar. - - Closed issues can still be valid duplicates if they clearly match. - - Return fewer matches rather than speculative ones. - - If confidence is low, return an empty list. - - Include at most five issue numbers. - - After analysis, provide a short reason for your decision. - - output-schema: | - { - "type": "object", - "properties": { - "issues": { - "type": "array", - "items": { - "type": "string" - } - }, - "reason": { "type": "string" } - }, - "required": ["issues", "reason"], - "additionalProperties": false - } - - - id: normalize-all - name: Normalize pass 1 output - env: - CODEX_OUTPUT: ${{ steps.codex-all.outputs.final-message }} - CURRENT_ISSUE_NUMBER: ${{ github.event.issue.number }} - run: | - set -eo pipefail - - raw=${CODEX_OUTPUT//$'\r'/} - parsed=false - issues='[]' - reason='' - - if [ -n "$raw" ] && printf '%s' "$raw" | jq -e 'type == "object" and (.issues | type == "array")' >/dev/null 2>&1; then - parsed=true - issues=$(printf '%s' "$raw" | jq -c '[.issues[] | tostring]') - reason=$(printf '%s' "$raw" | jq -r '.reason // ""') - else - reason='Pass 1 output was empty or invalid JSON.' - fi - - filtered=$(jq -cn --argjson issues "$issues" --arg current "$CURRENT_ISSUE_NUMBER" '[ - $issues[] - | tostring - | select(. != $current) - ] | reduce .[] as $issue ([]; if index($issue) then . else . + [$issue] end) | .[:5]') - - has_matches=false - if [ "$(jq 'length' <<< "$filtered")" -gt 0 ]; then - has_matches=true - fi - - echo "Pass 1 parsed: $parsed" - echo "Pass 1 matches after filtering: $(jq 'length' <<< "$filtered")" - echo "Pass 1 reason: $reason" - - { - echo "issues_json=$filtered" - echo "reason<> "$GITHUB_OUTPUT" - - gather-duplicates-open: - name: Identify potential duplicates (open issues fallback) - # Pass 1 may drop sudo on the runner, so run the fallback in a fresh job. - needs: gather-duplicates-all - if: ${{ needs.gather-duplicates-all.result == 'success' && needs.gather-duplicates-all.outputs.has_matches != 'true' }} - runs-on: ubuntu-latest - permissions: - contents: read - outputs: - issues_json: ${{ steps.normalize-open.outputs.issues_json }} - reason: ${{ steps.normalize-open.outputs.reason }} - has_matches: ${{ steps.normalize-open.outputs.has_matches }} - steps: - - uses: actions/checkout@v6 - - - name: Prepare Codex inputs - env: - GH_TOKEN: ${{ github.token }} - REPO: ${{ github.repository }} - ISSUE_NUMBER: ${{ github.event.issue.number }} - run: | - set -eo pipefail - - CURRENT_ISSUE_FILE=codex-current-issue.json - EXISTING_OPEN_FILE=codex-existing-issues-open.json - - gh issue list --repo "$REPO" \ - --json number,title,body,createdAt,updatedAt,state,labels \ - --limit 1000 \ - --state open \ - --search "sort:created-desc" \ - | jq '[.[] | { - number, - title, - body: ((.body // "")[0:4000]), - createdAt, - updatedAt, - state, - labels: ((.labels // []) | map(.name)) - }]' \ - > "$EXISTING_OPEN_FILE" - - gh issue view "$ISSUE_NUMBER" \ - --repo "$REPO" \ - --json number,title,body \ - | jq '{number, title, body: ((.body // "")[0:4000])}' \ - > "$CURRENT_ISSUE_FILE" - - echo "Prepared fallback duplicate detection input files." - echo "open_issue_count=$(jq 'length' "$EXISTING_OPEN_FILE")" - - - id: codex-open - name: Find duplicates (pass 2, open issues) - uses: openai/codex-action@main - with: - openai-api-key: ${{ secrets.CODEX_OPENAI_API_KEY }} - allow-users: "*" - prompt: | - You are an assistant that triages new GitHub issues by identifying potential duplicates. - - This is a fallback pass because a broad search did not find convincing matches. - - You will receive the following JSON files located in the current working directory: - - `codex-current-issue.json`: JSON object describing the newly created issue (fields: number, title, body). - - `codex-existing-issues-open.json`: JSON array of open issues only. - - Instructions: - - Search only these active unresolved issues for duplicates of the current issue. - - Prioritize concrete overlap in symptoms, reproduction details, error signatures, and user intent. - - Prefer fewer, higher-confidence matches. - - If confidence is low, return an empty list. - - Include at most five issue numbers. - - After analysis, provide a short reason for your decision. - - output-schema: | - { - "type": "object", - "properties": { - "issues": { - "type": "array", - "items": { - "type": "string" - } - }, - "reason": { "type": "string" } - }, - "required": ["issues", "reason"], - "additionalProperties": false - } - - - id: normalize-open - name: Normalize pass 2 output - env: - CODEX_OUTPUT: ${{ steps.codex-open.outputs.final-message }} - CURRENT_ISSUE_NUMBER: ${{ github.event.issue.number }} - run: | - set -eo pipefail - - raw=${CODEX_OUTPUT//$'\r'/} - parsed=false - issues='[]' - reason='' - - if [ -n "$raw" ] && printf '%s' "$raw" | jq -e 'type == "object" and (.issues | type == "array")' >/dev/null 2>&1; then - parsed=true - issues=$(printf '%s' "$raw" | jq -c '[.issues[] | tostring]') - reason=$(printf '%s' "$raw" | jq -r '.reason // ""') - else - reason='Pass 2 output was empty or invalid JSON.' - fi - - filtered=$(jq -cn --argjson issues "$issues" --arg current "$CURRENT_ISSUE_NUMBER" '[ - $issues[] - | tostring - | select(. != $current) - ] | reduce .[] as $issue ([]; if index($issue) then . else . + [$issue] end) | .[:5]') - - has_matches=false - if [ "$(jq 'length' <<< "$filtered")" -gt 0 ]; then - has_matches=true - fi - - echo "Pass 2 parsed: $parsed" - echo "Pass 2 matches after filtering: $(jq 'length' <<< "$filtered")" - echo "Pass 2 reason: $reason" - - { - echo "issues_json=$filtered" - echo "reason<> "$GITHUB_OUTPUT" - - select-final: - name: Select final duplicate set - needs: - - gather-duplicates-all - - gather-duplicates-open - if: ${{ always() && needs.gather-duplicates-all.result == 'success' && (needs.gather-duplicates-open.result == 'success' || needs.gather-duplicates-open.result == 'skipped') }} - runs-on: ubuntu-latest - permissions: - contents: read - outputs: - codex_output: ${{ steps.select-final.outputs.codex_output }} - steps: - - id: select-final - name: Select final duplicate set - env: - PASS1_ISSUES: ${{ needs.gather-duplicates-all.outputs.issues_json }} - PASS1_REASON: ${{ needs.gather-duplicates-all.outputs.reason }} - PASS2_ISSUES: ${{ needs.gather-duplicates-open.outputs.issues_json }} - PASS2_REASON: ${{ needs.gather-duplicates-open.outputs.reason }} - PASS1_HAS_MATCHES: ${{ needs.gather-duplicates-all.outputs.has_matches }} - PASS2_HAS_MATCHES: ${{ needs.gather-duplicates-open.outputs.has_matches }} - run: | - set -eo pipefail - - selected_issues='[]' - selected_reason='No plausible duplicates found.' - selected_pass='none' - - if [ "$PASS1_HAS_MATCHES" = "true" ]; then - selected_issues=${PASS1_ISSUES:-'[]'} - selected_reason=${PASS1_REASON:-'Pass 1 found duplicates.'} - selected_pass='all' - fi - - if [ "$PASS2_HAS_MATCHES" = "true" ]; then - selected_issues=${PASS2_ISSUES:-'[]'} - selected_reason=${PASS2_REASON:-'Pass 2 found duplicates.'} - selected_pass='open-fallback' - fi - - final_json=$(jq -cn \ - --argjson issues "$selected_issues" \ - --arg reason "$selected_reason" \ - --arg pass "$selected_pass" \ - '{issues: $issues, reason: $reason, pass: $pass}') - - echo "Final pass used: $selected_pass" - echo "Final duplicate count: $(jq '.issues | length' <<< "$final_json")" - echo "Final reason: $(jq -r '.reason' <<< "$final_json")" - - { - echo "codex_output<> "$GITHUB_OUTPUT" - - comment-on-issue: - name: Comment with potential duplicates - needs: select-final - if: ${{ always() && needs.select-final.result == 'success' }} - runs-on: ubuntu-latest - permissions: - contents: read - issues: write - steps: - - name: Comment on issue - uses: actions/github-script@v8 - env: - CODEX_OUTPUT: ${{ needs.select-final.outputs.codex_output }} - with: - github-token: ${{ github.token }} - script: | - const raw = process.env.CODEX_OUTPUT ?? ''; - let parsed; - try { - parsed = JSON.parse(raw); - } catch (error) { - core.info(`Codex output was not valid JSON. Raw output: ${raw}`); - core.info(`Parse error: ${error.message}`); - return; - } - - const issues = Array.isArray(parsed?.issues) ? parsed.issues : []; - const currentIssueNumber = String(context.payload.issue.number); - const passUsed = typeof parsed?.pass === 'string' ? parsed.pass : 'unknown'; - const reason = typeof parsed?.reason === 'string' ? parsed.reason : ''; - - console.log(`Current issue number: ${currentIssueNumber}`); - console.log(`Pass used: ${passUsed}`); - if (reason) { - console.log(`Reason: ${reason}`); - } - console.log(issues); - - const filteredIssues = [...new Set(issues.map((value) => String(value)))].filter((value) => value !== currentIssueNumber).slice(0, 5); - - if (filteredIssues.length === 0) { - core.info('Codex reported no potential duplicates.'); - return; - } - - const lines = [ - 'Potential duplicates detected. Please review them and close your issue if it is a duplicate.', - '', - ...filteredIssues.map((value) => `- #${String(value)}`), - '', - '*Powered by [Codex Action](https://github.com/openai/codex-action)*']; - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.payload.issue.number, - body: lines.join("\n"), - }); - - - name: Remove codex-deduplicate label - if: ${{ always() && github.event.action == 'labeled' && github.event.label.name == 'codex-deduplicate' }} - env: - GH_TOKEN: ${{ github.token }} - GH_REPO: ${{ github.repository }} - ISSUE_NUMBER: ${{ github.event.issue.number }} - run: | - gh issue edit "$ISSUE_NUMBER" --remove-label codex-deduplicate || true - echo "Attempted to remove label: codex-deduplicate" diff --git a/.github/workflows/issue-labeler.yml b/.github/workflows/issue-labeler.yml deleted file mode 100644 index 174b219de..000000000 --- a/.github/workflows/issue-labeler.yml +++ /dev/null @@ -1,133 +0,0 @@ -name: Issue Labeler - -on: - issues: - types: - - opened - - labeled - -jobs: - gather-labels: - name: Generate label suggestions - # Prevent runs on forks (requires OpenAI API key, wastes Actions minutes) - if: github.repository == 'openai/codex' && (github.event.action == 'opened' || (github.event.action == 'labeled' && github.event.label.name == 'codex-label')) - runs-on: ubuntu-latest - permissions: - contents: read - outputs: - codex_output: ${{ steps.codex.outputs.final-message }} - steps: - - uses: actions/checkout@v6 - - - id: codex - uses: openai/codex-action@main - with: - openai-api-key: ${{ secrets.CODEX_OPENAI_API_KEY }} - allow-users: "*" - prompt: | - You are an assistant that reviews GitHub issues for the repository. - - Your job is to choose the most appropriate labels for the issue described later in this prompt. - Follow these rules: - - - Add one (and only one) of the following three labels to distinguish the type of issue. Default to "bug" if unsure. - 1. bug — Reproducible defects in Codex products (CLI, VS Code extension, web, auth). - 2. enhancement — Feature requests or usability improvements that ask for new capabilities, better ergonomics, or quality-of-life tweaks. - 3. documentation — Updates or corrections needed in docs/README/config references (broken links, missing examples, outdated keys, clarification requests). - - - If applicable, add one of the following labels to specify which sub-product or product surface the issue relates to. - 1. CLI — the Codex command line interface. - 2. extension — VS Code (or other IDE) extension-specific issues. - 3. app - Issues related to the Codex desktop application. - 4. codex-web — Issues targeting the Codex web UI/Cloud experience. - 5. github-action — Issues with the Codex GitHub action. - 6. iOS — Issues with the Codex iOS app. - - - Additionally add zero or more of the following labels that are relevant to the issue content. Prefer a small set of precise labels over many broad ones. - 1. windows-os — Bugs or friction specific to Windows environments (always when PowerShell is mentioned, path handling, copy/paste, OS-specific auth or tooling failures). - 2. mcp — Topics involving Model Context Protocol servers/clients. - 3. mcp-server — Problems related to the codex mcp-server command, where codex runs as an MCP server. - 4. azure — Problems or requests tied to Azure OpenAI deployments. - 5. model-behavior — Undesirable LLM behavior: forgetting goals, refusing work, hallucinating environment details, quota misreports, or other reasoning/performance anomalies. - 6. code-review — Issues related to the code review feature or functionality. - 7. safety-check - Issues related to cyber risk detection or trusted access verification. - 8. auth - Problems related to authentication, login, or access tokens. - 9. codex-exec - Problems related to the "codex exec" command or functionality. - 10. context-management - Problems related to compaction, context windows, or available context reporting. - 11. custom-model - Problems that involve using custom model providers, local models, or OSS models. - 12. rate-limits - Problems related to token limits, rate limits, or token usage reporting. - 13. sandbox - Issues related to local sandbox environments or tool call approvals to override sandbox restrictions. - 14. tool-calls - Problems related to specific tool call invocations including unexpected errors, failures, or hangs. - 15. TUI - Problems with the terminal user interface (TUI) including keyboard shortcuts, copy & pasting, menus, or screen update issues. - - Issue number: ${{ github.event.issue.number }} - - Issue title: - ${{ github.event.issue.title }} - - Issue body: - ${{ github.event.issue.body }} - - Repository full name: - ${{ github.repository }} - - output-schema: | - { - "type": "object", - "properties": { - "labels": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": ["labels"], - "additionalProperties": false - } - - apply-labels: - name: Apply labels from Codex output - needs: gather-labels - if: ${{ needs.gather-labels.result != 'skipped' }} - runs-on: ubuntu-latest - permissions: - contents: read - issues: write - env: - GH_TOKEN: ${{ github.token }} - GH_REPO: ${{ github.repository }} - ISSUE_NUMBER: ${{ github.event.issue.number }} - CODEX_OUTPUT: ${{ needs.gather-labels.outputs.codex_output }} - steps: - - name: Apply labels - run: | - json=${CODEX_OUTPUT//$'\r'/} - if [ -z "$json" ]; then - echo "Codex produced no output. Skipping label application." - exit 0 - fi - - if ! printf '%s' "$json" | jq -e 'type == "object" and (.labels | type == "array")' >/dev/null 2>&1; then - echo "Codex output did not include a labels array. Raw output: $json" - exit 0 - fi - - labels=$(printf '%s' "$json" | jq -r '.labels[] | tostring') - if [ -z "$labels" ]; then - echo "Codex returned an empty array. Nothing to do." - exit 0 - fi - - cmd=(gh issue edit "$ISSUE_NUMBER") - while IFS= read -r label; do - cmd+=(--add-label "$label") - done <<< "$labels" - - "${cmd[@]}" || true - - - name: Remove codex-label trigger - if: ${{ always() && github.event.action == 'labeled' && github.event.label.name == 'codex-label' }} - run: | - gh issue edit "$ISSUE_NUMBER" --remove-label codex-label || true - echo "Attempted to remove label: codex-label" diff --git a/.github/workflows/rust-release-argument-comment-lint.yml b/.github/workflows/rust-release-argument-comment-lint.yml deleted file mode 100644 index a0d12d6db..000000000 --- a/.github/workflows/rust-release-argument-comment-lint.yml +++ /dev/null @@ -1,103 +0,0 @@ -name: rust-release-argument-comment-lint - -on: - workflow_call: - inputs: - publish: - required: true - type: boolean - -jobs: - skip: - if: ${{ !inputs.publish }} - runs-on: ubuntu-latest - steps: - - run: echo "Skipping argument-comment-lint release assets for prerelease tag" - - build: - if: ${{ inputs.publish }} - name: Build - ${{ matrix.runner }} - ${{ matrix.target }} - runs-on: ${{ matrix.runs_on || matrix.runner }} - timeout-minutes: 60 - - strategy: - fail-fast: false - matrix: - include: - - runner: macos-15-xlarge - target: aarch64-apple-darwin - archive_name: argument-comment-lint-aarch64-apple-darwin.tar.gz - lib_name: libargument_comment_lint@nightly-2025-09-18-aarch64-apple-darwin.dylib - runner_binary: argument-comment-lint - cargo_dylint_binary: cargo-dylint - - runner: ubuntu-24.04 - target: x86_64-unknown-linux-gnu - archive_name: argument-comment-lint-x86_64-unknown-linux-gnu.tar.gz - lib_name: libargument_comment_lint@nightly-2025-09-18-x86_64-unknown-linux-gnu.so - runner_binary: argument-comment-lint - cargo_dylint_binary: cargo-dylint - - runner: ubuntu-24.04-arm - target: aarch64-unknown-linux-gnu - archive_name: argument-comment-lint-aarch64-unknown-linux-gnu.tar.gz - lib_name: libargument_comment_lint@nightly-2025-09-18-aarch64-unknown-linux-gnu.so - runner_binary: argument-comment-lint - cargo_dylint_binary: cargo-dylint - - runner: windows-x64 - target: x86_64-pc-windows-msvc - archive_name: argument-comment-lint-x86_64-pc-windows-msvc.zip - lib_name: argument_comment_lint@nightly-2025-09-18-x86_64-pc-windows-msvc.dll - runner_binary: argument-comment-lint.exe - cargo_dylint_binary: cargo-dylint.exe - runs_on: - group: codex-runners - labels: codex-windows-x64 - - steps: - - uses: actions/checkout@v6 - - - uses: dtolnay/rust-toolchain@1.93.0 - with: - toolchain: nightly-2025-09-18 - targets: ${{ matrix.target }} - components: llvm-tools-preview, rustc-dev, rust-src - - - name: Install tooling - shell: bash - run: | - install_root="${RUNNER_TEMP}/argument-comment-lint-tools" - cargo install --locked cargo-dylint --root "$install_root" - cargo install --locked dylint-link - echo "INSTALL_ROOT=$install_root" >> "$GITHUB_ENV" - - - name: Cargo build - working-directory: tools/argument-comment-lint - shell: bash - run: cargo build --release --target ${{ matrix.target }} - - - name: Stage artifact - shell: bash - run: | - dest="dist/argument-comment-lint/${{ matrix.target }}" - mkdir -p "$dest" - package_root="${RUNNER_TEMP}/argument-comment-lint" - rm -rf "$package_root" - mkdir -p "$package_root/bin" "$package_root/lib" - - cp "tools/argument-comment-lint/target/${{ matrix.target }}/release/${{ matrix.runner_binary }}" \ - "$package_root/bin/${{ matrix.runner_binary }}" - cp "${INSTALL_ROOT}/bin/${{ matrix.cargo_dylint_binary }}" \ - "$package_root/bin/${{ matrix.cargo_dylint_binary }}" - cp "tools/argument-comment-lint/target/${{ matrix.target }}/release/${{ matrix.lib_name }}" \ - "$package_root/lib/${{ matrix.lib_name }}" - - archive_path="$dest/${{ matrix.archive_name }}" - if [[ "${{ runner.os }}" == "Windows" ]]; then - (cd "${RUNNER_TEMP}" && 7z a "$GITHUB_WORKSPACE/$archive_path" argument-comment-lint >/dev/null) - else - (cd "${RUNNER_TEMP}" && tar -czf "$GITHUB_WORKSPACE/$archive_path" argument-comment-lint) - fi - - - uses: actions/upload-artifact@v7 - with: - name: argument-comment-lint-${{ matrix.target }} - path: dist/argument-comment-lint/${{ matrix.target }}/* diff --git a/.github/workflows/rust-release-prepare.yml b/.github/workflows/rust-release-prepare.yml deleted file mode 100644 index c9f11f54f..000000000 --- a/.github/workflows/rust-release-prepare.yml +++ /dev/null @@ -1,53 +0,0 @@ -name: rust-release-prepare -on: - workflow_dispatch: - schedule: - - cron: "0 */4 * * *" - -concurrency: - group: ${{ github.workflow }} - cancel-in-progress: false - -permissions: - contents: write - pull-requests: write - -jobs: - prepare: - # Prevent scheduled runs on forks (no secrets, wastes Actions minutes) - if: github.repository == 'openai/codex' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - with: - ref: main - fetch-depth: 0 - - - name: Update models.json - env: - OPENAI_API_KEY: ${{ secrets.CODEX_OPENAI_API_KEY }} - run: | - set -euo pipefail - - client_version="99.99.99" - terminal_info="github-actions" - user_agent="codex_cli_rs/99.99.99 (Linux $(uname -r); $(uname -m)) ${terminal_info}" - base_url="${OPENAI_BASE_URL:-https://chatgpt.com/backend-api/codex}" - - headers=( - -H "Authorization: Bearer ${OPENAI_API_KEY}" - -H "User-Agent: ${user_agent}" - ) - - url="${base_url%/}/models?client_version=${client_version}" - curl --http1.1 --fail --show-error --location "${headers[@]}" "${url}" | jq '.' > codex-rs/core/models.json - - - name: Open pull request (if changed) - uses: peter-evans/create-pull-request@v8 - with: - commit-message: "Update models.json" - title: "Update models.json" - body: "Automated update of models.json." - branch: "bot/update-models-json" - reviewers: "pakrym-oai,aibrahim-oai" - delete-branch: true diff --git a/.github/workflows/rust-release-windows.yml b/.github/workflows/rust-release-windows.yml deleted file mode 100644 index f762fbc4b..000000000 --- a/.github/workflows/rust-release-windows.yml +++ /dev/null @@ -1,264 +0,0 @@ -name: rust-release-windows - -on: - workflow_call: - inputs: - release-lto: - required: true - type: string - secrets: - AZURE_TRUSTED_SIGNING_CLIENT_ID: - required: true - AZURE_TRUSTED_SIGNING_TENANT_ID: - required: true - AZURE_TRUSTED_SIGNING_SUBSCRIPTION_ID: - required: true - AZURE_TRUSTED_SIGNING_ENDPOINT: - required: true - AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: - required: true - AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME: - required: true - -jobs: - build-windows-binaries: - name: Build Windows binaries - ${{ matrix.runner }} - ${{ matrix.target }} - ${{ matrix.bundle }} - runs-on: ${{ matrix.runs_on }} - timeout-minutes: 60 - permissions: - contents: read - defaults: - run: - working-directory: codex-rs - env: - CARGO_PROFILE_RELEASE_LTO: ${{ inputs.release-lto }} - - strategy: - fail-fast: false - matrix: - include: - - runner: windows-x64 - target: x86_64-pc-windows-msvc - bundle: primary - build_args: --bin codex --bin codex-responses-api-proxy - runs_on: - group: codex-runners - labels: codex-windows-x64 - - runner: windows-arm64 - target: aarch64-pc-windows-msvc - bundle: primary - build_args: --bin codex --bin codex-responses-api-proxy - runs_on: - group: codex-runners - labels: codex-windows-arm64 - - runner: windows-x64 - target: x86_64-pc-windows-msvc - bundle: helpers - build_args: --bin codex-windows-sandbox-setup --bin codex-command-runner - runs_on: - group: codex-runners - labels: codex-windows-x64 - - runner: windows-arm64 - target: aarch64-pc-windows-msvc - bundle: helpers - build_args: --bin codex-windows-sandbox-setup --bin codex-command-runner - runs_on: - group: codex-runners - labels: codex-windows-arm64 - - steps: - - uses: actions/checkout@v6 - - name: Print runner specs (Windows) - shell: powershell - run: | - $computer = Get-CimInstance Win32_ComputerSystem - $cpu = Get-CimInstance Win32_Processor | Select-Object -First 1 - $ramGiB = [math]::Round($computer.TotalPhysicalMemory / 1GB, 1) - Write-Host "Runner: $env:RUNNER_NAME" - Write-Host "OS: $([System.Environment]::OSVersion.VersionString)" - Write-Host "CPU: $($cpu.Name)" - Write-Host "Logical CPUs: $($computer.NumberOfLogicalProcessors)" - Write-Host "Physical CPUs: $($computer.NumberOfProcessors)" - Write-Host "Total RAM: $ramGiB GiB" - Write-Host "Disk usage:" - Get-PSDrive -PSProvider FileSystem | Format-Table -AutoSize Name, @{Name='Size(GB)';Expression={[math]::Round(($_.Used + $_.Free) / 1GB, 1)}}, @{Name='Free(GB)';Expression={[math]::Round($_.Free / 1GB, 1)}} - - uses: dtolnay/rust-toolchain@1.93.0 - with: - targets: ${{ matrix.target }} - - - name: Cargo build (Windows binaries) - shell: bash - run: | - cargo build --target ${{ matrix.target }} --release --timings ${{ matrix.build_args }} - - - name: Upload Cargo timings - uses: actions/upload-artifact@v7 - with: - name: cargo-timings-rust-release-windows-${{ matrix.target }}-${{ matrix.bundle }} - path: codex-rs/target/**/cargo-timings/cargo-timing.html - if-no-files-found: warn - - - name: Stage Windows binaries - shell: bash - run: | - output_dir="target/${{ matrix.target }}/release/staged-${{ matrix.bundle }}" - mkdir -p "$output_dir" - if [[ "${{ matrix.bundle }}" == "primary" ]]; then - cp target/${{ matrix.target }}/release/codex.exe "$output_dir/codex.exe" - cp target/${{ matrix.target }}/release/codex-responses-api-proxy.exe "$output_dir/codex-responses-api-proxy.exe" - else - cp target/${{ matrix.target }}/release/codex-windows-sandbox-setup.exe "$output_dir/codex-windows-sandbox-setup.exe" - cp target/${{ matrix.target }}/release/codex-command-runner.exe "$output_dir/codex-command-runner.exe" - fi - - - name: Upload Windows binaries - uses: actions/upload-artifact@v7 - with: - name: windows-binaries-${{ matrix.target }}-${{ matrix.bundle }} - path: | - codex-rs/target/${{ matrix.target }}/release/staged-${{ matrix.bundle }}/* - - build-windows: - needs: - - build-windows-binaries - name: Build - ${{ matrix.runner }} - ${{ matrix.target }} - runs-on: ${{ matrix.runs_on }} - timeout-minutes: 60 - permissions: - contents: read - id-token: write - defaults: - run: - working-directory: codex-rs - - strategy: - fail-fast: false - matrix: - include: - - runner: windows-x64 - target: x86_64-pc-windows-msvc - runs_on: - group: codex-runners - labels: codex-windows-x64 - - runner: windows-arm64 - target: aarch64-pc-windows-msvc - runs_on: - group: codex-runners - labels: codex-windows-arm64 - - steps: - - uses: actions/checkout@v6 - - - name: Download prebuilt Windows primary binaries - uses: actions/download-artifact@v8 - with: - name: windows-binaries-${{ matrix.target }}-primary - path: codex-rs/target/${{ matrix.target }}/release - - - name: Download prebuilt Windows helper binaries - uses: actions/download-artifact@v8 - with: - name: windows-binaries-${{ matrix.target }}-helpers - path: codex-rs/target/${{ matrix.target }}/release - - - name: Verify binaries - shell: bash - run: | - set -euo pipefail - ls -lh target/${{ matrix.target }}/release/codex.exe - ls -lh target/${{ matrix.target }}/release/codex-responses-api-proxy.exe - ls -lh target/${{ matrix.target }}/release/codex-windows-sandbox-setup.exe - ls -lh target/${{ matrix.target }}/release/codex-command-runner.exe - - - name: Sign Windows binaries with Azure Trusted Signing - uses: ./.github/actions/windows-code-sign - with: - target: ${{ matrix.target }} - client-id: ${{ secrets.AZURE_TRUSTED_SIGNING_CLIENT_ID }} - tenant-id: ${{ secrets.AZURE_TRUSTED_SIGNING_TENANT_ID }} - subscription-id: ${{ secrets.AZURE_TRUSTED_SIGNING_SUBSCRIPTION_ID }} - endpoint: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} - account-name: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} - certificate-profile-name: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} - - - name: Stage artifacts - shell: bash - run: | - dest="dist/${{ matrix.target }}" - mkdir -p "$dest" - - cp target/${{ matrix.target }}/release/codex.exe "$dest/codex-${{ matrix.target }}.exe" - cp target/${{ matrix.target }}/release/codex-responses-api-proxy.exe "$dest/codex-responses-api-proxy-${{ matrix.target }}.exe" - cp target/${{ matrix.target }}/release/codex-windows-sandbox-setup.exe "$dest/codex-windows-sandbox-setup-${{ matrix.target }}.exe" - cp target/${{ matrix.target }}/release/codex-command-runner.exe "$dest/codex-command-runner-${{ matrix.target }}.exe" - - - name: Install DotSlash - uses: facebook/install-dotslash@v2 - - - name: Compress artifacts - shell: bash - run: | - # Path that contains the uncompressed binaries for the current - # ${{ matrix.target }} - dest="dist/${{ matrix.target }}" - repo_root=$PWD - - # For compatibility with environments that lack the `zstd` tool we - # additionally create a `.tar.gz` and `.zip` for every Windows binary. - # The end result is: - # codex-.zst - # codex-.tar.gz - # codex-.zip - for f in "$dest"/*; do - base="$(basename "$f")" - # Skip files that are already archives (shouldn't happen, but be - # safe). - if [[ "$base" == *.tar.gz || "$base" == *.zip || "$base" == *.dmg ]]; then - continue - fi - - # Don't try to compress signature bundles. - if [[ "$base" == *.sigstore ]]; then - continue - fi - - # Create per-binary tar.gz - tar -C "$dest" -czf "$dest/${base}.tar.gz" "$base" - - # Create zip archive for Windows binaries. - # Must run from inside the dest dir so 7z won't embed the - # directory path inside the zip. - if [[ "$base" == "codex-${{ matrix.target }}.exe" ]]; then - # Bundle the sandbox helper binaries into the main codex zip so - # WinGet installs include the required helpers next to codex.exe. - # Fall back to the single-binary zip if the helpers are missing - # to avoid breaking releases. - bundle_dir="$(mktemp -d)" - runner_src="$dest/codex-command-runner-${{ matrix.target }}.exe" - setup_src="$dest/codex-windows-sandbox-setup-${{ matrix.target }}.exe" - if [[ -f "$runner_src" && -f "$setup_src" ]]; then - cp "$dest/$base" "$bundle_dir/$base" - cp "$runner_src" "$bundle_dir/codex-command-runner.exe" - cp "$setup_src" "$bundle_dir/codex-windows-sandbox-setup.exe" - # Use an absolute path so bundle zips land in the real dist - # dir even when 7z runs from a temp directory. - (cd "$bundle_dir" && 7z a "$repo_root/$dest/${base}.zip" .) - else - echo "warning: missing sandbox binaries; falling back to single-binary zip" - echo "warning: expected $runner_src and $setup_src" - (cd "$dest" && 7z a "${base}.zip" "$base") - fi - rm -rf "$bundle_dir" - else - (cd "$dest" && 7z a "${base}.zip" "$base") - fi - - # Keep raw executables and produce .zst alongside them. - "${GITHUB_WORKSPACE}/.github/workflows/zstd" -T0 -19 "$dest/$base" - done - - - uses: actions/upload-artifact@v7 - with: - name: ${{ matrix.target }} - path: | - codex-rs/dist/${{ matrix.target }}/* diff --git a/.github/workflows/rust-release-zsh.yml b/.github/workflows/rust-release-zsh.yml deleted file mode 100644 index a0f71aa73..000000000 --- a/.github/workflows/rust-release-zsh.yml +++ /dev/null @@ -1,95 +0,0 @@ -name: rust-release-zsh - -on: - workflow_call: - -env: - ZSH_COMMIT: 77045ef899e53b9598bebc5a41db93a548a40ca6 - ZSH_PATCH: codex-rs/shell-escalation/patches/zsh-exec-wrapper.patch - -jobs: - linux: - name: Build zsh (Linux) - ${{ matrix.variant }} - ${{ matrix.target }} - runs-on: ${{ matrix.runner }} - timeout-minutes: 30 - container: - image: ${{ matrix.image }} - - strategy: - fail-fast: false - matrix: - include: - - runner: ubuntu-24.04 - target: x86_64-unknown-linux-musl - variant: ubuntu-24.04 - image: ubuntu:24.04 - archive_name: codex-zsh-x86_64-unknown-linux-musl.tar.gz - - runner: ubuntu-24.04-arm - target: aarch64-unknown-linux-musl - variant: ubuntu-24.04 - image: arm64v8/ubuntu:24.04 - archive_name: codex-zsh-aarch64-unknown-linux-musl.tar.gz - - steps: - - name: Install build prerequisites - shell: bash - run: | - set -euo pipefail - apt-get update - DEBIAN_FRONTEND=noninteractive apt-get install -y \ - autoconf \ - bison \ - build-essential \ - ca-certificates \ - gettext \ - git \ - libncursesw5-dev - - - uses: actions/checkout@v6 - - - name: Build, smoke-test, and stage zsh artifact - shell: bash - run: | - "${GITHUB_WORKSPACE}/.github/scripts/build-zsh-release-artifact.sh" \ - "dist/zsh/${{ matrix.target }}/${{ matrix.archive_name }}" - - - uses: actions/upload-artifact@v7 - with: - name: codex-zsh-${{ matrix.target }} - path: dist/zsh/${{ matrix.target }}/* - - darwin: - name: Build zsh (macOS) - ${{ matrix.variant }} - ${{ matrix.target }} - runs-on: ${{ matrix.runner }} - timeout-minutes: 30 - - strategy: - fail-fast: false - matrix: - include: - - runner: macos-15-xlarge - target: aarch64-apple-darwin - variant: macos-15 - archive_name: codex-zsh-aarch64-apple-darwin.tar.gz - - steps: - - name: Install build prerequisites - shell: bash - run: | - set -euo pipefail - if ! command -v autoconf >/dev/null 2>&1; then - brew install autoconf - fi - - - uses: actions/checkout@v6 - - - name: Build, smoke-test, and stage zsh artifact - shell: bash - run: | - "${GITHUB_WORKSPACE}/.github/scripts/build-zsh-release-artifact.sh" \ - "dist/zsh/${{ matrix.target }}/${{ matrix.archive_name }}" - - - uses: actions/upload-artifact@v7 - with: - name: codex-zsh-${{ matrix.target }} - path: dist/zsh/${{ matrix.target }}/* diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml deleted file mode 100644 index 1ec9bd28b..000000000 --- a/.github/workflows/rust-release.yml +++ /dev/null @@ -1,722 +0,0 @@ -# Release workflow for codex-rs. -# To release, follow a workflow like: -# ``` -# git tag -a rust-v0.1.0 -m "Release 0.1.0" -# git push origin rust-v0.1.0 -# ``` - -name: rust-release -on: - push: - tags: - - "rust-v*.*.*" - -concurrency: - group: ${{ github.workflow }} - cancel-in-progress: true - -jobs: - tag-check: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - uses: dtolnay/rust-toolchain@1.92 - - name: Validate tag matches Cargo.toml version - shell: bash - run: | - set -euo pipefail - echo "::group::Tag validation" - - # 1. Must be a tag and match the regex - [[ "${GITHUB_REF_TYPE}" == "tag" ]] \ - || { echo "❌ Not a tag push"; exit 1; } - [[ "${GITHUB_REF_NAME}" =~ ^rust-v[0-9]+\.[0-9]+\.[0-9]+(-(alpha|beta)(\.[0-9]+)?)?$ ]] \ - || { echo "❌ Tag '${GITHUB_REF_NAME}' doesn't match expected format"; exit 1; } - - # 2. Extract versions - tag_ver="${GITHUB_REF_NAME#rust-v}" - cargo_ver="$(grep -m1 '^version' codex-rs/Cargo.toml \ - | sed -E 's/version *= *"([^"]+)".*/\1/')" - - # 3. Compare - [[ "${tag_ver}" == "${cargo_ver}" ]] \ - || { echo "❌ Tag ${tag_ver} ≠ Cargo.toml ${cargo_ver}"; exit 1; } - - echo "✅ Tag and Cargo.toml agree (${tag_ver})" - echo "::endgroup::" - - build: - needs: tag-check - name: Build - ${{ matrix.runner }} - ${{ matrix.target }} - runs-on: ${{ matrix.runs_on || matrix.runner }} - timeout-minutes: 60 - permissions: - contents: read - id-token: write - defaults: - run: - working-directory: codex-rs - env: - # 2026-03-04: temporarily change releases to use thin LTO because - # Ubuntu ARM is timing out at 60 minutes. - CARGO_PROFILE_RELEASE_LTO: ${{ contains(github.ref_name, '-alpha') && 'thin' || 'thin' }} - - strategy: - fail-fast: false - matrix: - include: - - runner: macos-15-xlarge - target: aarch64-apple-darwin - - runner: macos-15-xlarge - target: x86_64-apple-darwin - - runner: ubuntu-24.04 - target: x86_64-unknown-linux-musl - - runner: ubuntu-24.04 - target: x86_64-unknown-linux-gnu - - runner: ubuntu-24.04-arm - target: aarch64-unknown-linux-musl - - runner: ubuntu-24.04-arm - target: aarch64-unknown-linux-gnu - - steps: - - uses: actions/checkout@v6 - - name: Print runner specs (Linux) - if: ${{ runner.os == 'Linux' }} - shell: bash - run: | - set -euo pipefail - cpu_model="$(lscpu | awk -F: '/Model name/ {gsub(/^[ \t]+/, "", $2); print $2; exit}')" - total_ram="$(awk '/MemTotal/ {printf "%.1f GiB\n", $2 / 1024 / 1024}' /proc/meminfo)" - echo "Runner: ${RUNNER_NAME:-unknown}" - echo "OS: $(uname -a)" - echo "CPU model: ${cpu_model}" - echo "Logical CPUs: $(nproc)" - echo "Total RAM: ${total_ram}" - echo "Disk usage:" - df -h . - - name: Print runner specs (macOS) - if: ${{ runner.os == 'macOS' }} - shell: bash - run: | - set -euo pipefail - total_ram="$(sysctl -n hw.memsize | awk '{printf "%.1f GiB\n", $1 / 1024 / 1024 / 1024}')" - echo "Runner: ${RUNNER_NAME:-unknown}" - echo "OS: $(sw_vers -productName) $(sw_vers -productVersion)" - echo "Hardware model: $(sysctl -n hw.model)" - echo "CPU architecture: $(uname -m)" - echo "Logical CPUs: $(sysctl -n hw.logicalcpu)" - echo "Physical CPUs: $(sysctl -n hw.physicalcpu)" - echo "Total RAM: ${total_ram}" - echo "Disk usage:" - df -h . - - name: Install Linux bwrap build dependencies - if: ${{ runner.os == 'Linux' }} - shell: bash - run: | - set -euo pipefail - sudo apt-get update -y - sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends pkg-config libcap-dev - - name: Install UBSan runtime (musl) - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl' }} - shell: bash - run: | - set -euo pipefail - if command -v apt-get >/dev/null 2>&1; then - sudo apt-get update -y - sudo DEBIAN_FRONTEND=noninteractive apt-get install -y libubsan1 - fi - - uses: dtolnay/rust-toolchain@1.93.0 - with: - targets: ${{ matrix.target }} - - - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} - name: Use hermetic Cargo home (musl) - shell: bash - run: | - set -euo pipefail - cargo_home="${GITHUB_WORKSPACE}/.cargo-home" - mkdir -p "${cargo_home}/bin" - echo "CARGO_HOME=${cargo_home}" >> "$GITHUB_ENV" - echo "${cargo_home}/bin" >> "$GITHUB_PATH" - : > "${cargo_home}/config.toml" - - - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} - name: Install Zig - uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2 - with: - version: 0.14.0 - - - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} - name: Install musl build tools - env: - TARGET: ${{ matrix.target }} - run: bash "${GITHUB_WORKSPACE}/.github/scripts/install-musl-build-tools.sh" - - - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} - name: Configure rustc UBSan wrapper (musl host) - shell: bash - run: | - set -euo pipefail - ubsan="" - if command -v ldconfig >/dev/null 2>&1; then - ubsan="$(ldconfig -p | grep -m1 'libubsan\.so\.1' | sed -E 's/.*=> (.*)$/\1/')" - fi - wrapper_root="${RUNNER_TEMP:-/tmp}" - wrapper="${wrapper_root}/rustc-ubsan-wrapper" - cat > "${wrapper}" <> "$GITHUB_ENV" - echo "RUSTC_WORKSPACE_WRAPPER=" >> "$GITHUB_ENV" - - - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} - name: Clear sanitizer flags (musl) - shell: bash - run: | - set -euo pipefail - # Avoid problematic aws-lc jitter entropy code path on musl builders. - echo "AWS_LC_SYS_NO_JITTER_ENTROPY=1" >> "$GITHUB_ENV" - target_no_jitter="AWS_LC_SYS_NO_JITTER_ENTROPY_${{ matrix.target }}" - target_no_jitter="${target_no_jitter//-/_}" - echo "${target_no_jitter}=1" >> "$GITHUB_ENV" - - # Clear global Rust flags so host/proc-macro builds don't pull in UBSan. - echo "RUSTFLAGS=" >> "$GITHUB_ENV" - echo "CARGO_ENCODED_RUSTFLAGS=" >> "$GITHUB_ENV" - echo "RUSTDOCFLAGS=" >> "$GITHUB_ENV" - # Override any runner-level Cargo config rustflags as well. - echo "CARGO_BUILD_RUSTFLAGS=" >> "$GITHUB_ENV" - echo "CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS=" >> "$GITHUB_ENV" - echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_RUSTFLAGS=" >> "$GITHUB_ENV" - echo "CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_RUSTFLAGS=" >> "$GITHUB_ENV" - echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_RUSTFLAGS=" >> "$GITHUB_ENV" - - sanitize_flags() { - local input="$1" - input="${input//-fsanitize=undefined/}" - input="${input//-fno-sanitize-recover=undefined/}" - input="${input//-fno-sanitize-trap=undefined/}" - echo "$input" - } - - cflags="$(sanitize_flags "${CFLAGS-}")" - cxxflags="$(sanitize_flags "${CXXFLAGS-}")" - echo "CFLAGS=${cflags}" >> "$GITHUB_ENV" - echo "CXXFLAGS=${cxxflags}" >> "$GITHUB_ENV" - - - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl' }} - name: Configure musl rusty_v8 artifact overrides - env: - TARGET: ${{ matrix.target }} - shell: bash - run: | - set -euo pipefail - version="$(python3 "${GITHUB_WORKSPACE}/.github/scripts/rusty_v8_bazel.py" resolved-v8-crate-version)" - release_tag="rusty-v8-v${version}" - base_url="https://github.com/openai/codex/releases/download/${release_tag}" - archive="https://github.com/openai/codex/releases/download/rusty-v8-v${version}/librusty_v8_release_${TARGET}.a.gz" - binding_dir="${RUNNER_TEMP}/rusty_v8" - binding_path="${binding_dir}/src_binding_release_${TARGET}.rs" - mkdir -p "${binding_dir}" - curl -fsSL "${base_url}/src_binding_release_${TARGET}.rs" -o "${binding_path}" - echo "RUSTY_V8_ARCHIVE=${archive}" >> "$GITHUB_ENV" - echo "RUSTY_V8_SRC_BINDING_PATH=${binding_path}" >> "$GITHUB_ENV" - - - name: Cargo build - shell: bash - run: | - echo "CARGO_PROFILE_RELEASE_LTO: ${CARGO_PROFILE_RELEASE_LTO}" - cargo build --target ${{ matrix.target }} --release --timings --bin codex --bin codex-responses-api-proxy - - - name: Upload Cargo timings - uses: actions/upload-artifact@v7 - with: - name: cargo-timings-rust-release-${{ matrix.target }} - path: codex-rs/target/**/cargo-timings/cargo-timing.html - if-no-files-found: warn - - - if: ${{ contains(matrix.target, 'linux') }} - name: Cosign Linux artifacts - uses: ./.github/actions/linux-code-sign - with: - target: ${{ matrix.target }} - artifacts-dir: ${{ github.workspace }}/codex-rs/target/${{ matrix.target }}/release - - - if: ${{ runner.os == 'macOS' }} - name: MacOS code signing (binaries) - uses: ./.github/actions/macos-code-sign - with: - target: ${{ matrix.target }} - sign-binaries: "true" - sign-dmg: "false" - apple-certificate: ${{ secrets.APPLE_CERTIFICATE_P12 }} - apple-certificate-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - apple-notarization-key-p8: ${{ secrets.APPLE_NOTARIZATION_KEY_P8 }} - apple-notarization-key-id: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }} - apple-notarization-issuer-id: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} - - - if: ${{ runner.os == 'macOS' }} - name: Build macOS dmg - shell: bash - run: | - set -euo pipefail - - target="${{ matrix.target }}" - release_dir="target/${target}/release" - dmg_root="${RUNNER_TEMP}/codex-dmg-root" - volname="Codex (${target})" - dmg_path="${release_dir}/codex-${target}.dmg" - - # The previous "MacOS code signing (binaries)" step signs + notarizes the - # built artifacts in `${release_dir}`. This step packages *those same* - # signed binaries into a dmg. - codex_binary_path="${release_dir}/codex" - proxy_binary_path="${release_dir}/codex-responses-api-proxy" - - rm -rf "$dmg_root" - mkdir -p "$dmg_root" - - if [[ ! -f "$codex_binary_path" ]]; then - echo "Binary $codex_binary_path not found" - exit 1 - fi - if [[ ! -f "$proxy_binary_path" ]]; then - echo "Binary $proxy_binary_path not found" - exit 1 - fi - - ditto "$codex_binary_path" "${dmg_root}/codex" - ditto "$proxy_binary_path" "${dmg_root}/codex-responses-api-proxy" - - rm -f "$dmg_path" - hdiutil create \ - -volname "$volname" \ - -srcfolder "$dmg_root" \ - -format UDZO \ - -ov \ - "$dmg_path" - - if [[ ! -f "$dmg_path" ]]; then - echo "dmg $dmg_path not found after build" - exit 1 - fi - - - if: ${{ runner.os == 'macOS' }} - name: MacOS code signing (dmg) - uses: ./.github/actions/macos-code-sign - with: - target: ${{ matrix.target }} - sign-binaries: "false" - sign-dmg: "true" - apple-certificate: ${{ secrets.APPLE_CERTIFICATE_P12 }} - apple-certificate-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - apple-notarization-key-p8: ${{ secrets.APPLE_NOTARIZATION_KEY_P8 }} - apple-notarization-key-id: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }} - apple-notarization-issuer-id: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} - - - name: Stage artifacts - shell: bash - run: | - dest="dist/${{ matrix.target }}" - mkdir -p "$dest" - - cp target/${{ matrix.target }}/release/codex "$dest/codex-${{ matrix.target }}" - cp target/${{ matrix.target }}/release/codex-responses-api-proxy "$dest/codex-responses-api-proxy-${{ matrix.target }}" - - if [[ "${{ matrix.target }}" == *linux* ]]; then - cp target/${{ matrix.target }}/release/codex.sigstore "$dest/codex-${{ matrix.target }}.sigstore" - cp target/${{ matrix.target }}/release/codex-responses-api-proxy.sigstore "$dest/codex-responses-api-proxy-${{ matrix.target }}.sigstore" - fi - - if [[ "${{ matrix.target }}" == *apple-darwin ]]; then - cp target/${{ matrix.target }}/release/codex-${{ matrix.target }}.dmg "$dest/codex-${{ matrix.target }}.dmg" - fi - - - name: Compress artifacts - shell: bash - run: | - # Path that contains the uncompressed binaries for the current - # ${{ matrix.target }} - dest="dist/${{ matrix.target }}" - - # For compatibility with environments that lack the `zstd` tool we - # additionally create a `.tar.gz` alongside every binary we publish. - # The end result is: - # codex-.zst (existing) - # codex-.tar.gz (new) - - # 1. Produce a .tar.gz for every file in the directory *before* we - # run `zstd --rm`, because that flag deletes the original files. - for f in "$dest"/*; do - base="$(basename "$f")" - # Skip files that are already archives (shouldn't happen, but be - # safe). - if [[ "$base" == *.tar.gz || "$base" == *.zip || "$base" == *.dmg ]]; then - continue - fi - - # Don't try to compress signature bundles. - if [[ "$base" == *.sigstore ]]; then - continue - fi - - # Create per-binary tar.gz - tar -C "$dest" -czf "$dest/${base}.tar.gz" "$base" - - # Also create .zst and remove the uncompressed binaries to keep - # non-Windows artifact directories small. - zstd -T0 -19 --rm "$dest/$base" - done - - - uses: actions/upload-artifact@v7 - with: - name: ${{ matrix.target }} - # Upload the per-binary .zst files as well as the new .tar.gz - # equivalents we generated in the previous step. - path: | - codex-rs/dist/${{ matrix.target }}/* - - build-windows: - needs: tag-check - uses: ./.github/workflows/rust-release-windows.yml - with: - release-lto: ${{ contains(github.ref_name, '-alpha') && 'thin' || 'fat' }} - secrets: inherit - - argument-comment-lint-release-assets: - name: argument-comment-lint release assets - needs: tag-check - uses: ./.github/workflows/rust-release-argument-comment-lint.yml - with: - publish: true - - zsh-release-assets: - name: zsh release assets - needs: tag-check - uses: ./.github/workflows/rust-release-zsh.yml - - release: - needs: - - build - - build-windows - - argument-comment-lint-release-assets - - zsh-release-assets - name: release - runs-on: ubuntu-latest - permissions: - contents: write - actions: read - outputs: - version: ${{ steps.release_name.outputs.name }} - tag: ${{ github.ref_name }} - should_publish_npm: ${{ steps.npm_publish_settings.outputs.should_publish }} - npm_tag: ${{ steps.npm_publish_settings.outputs.npm_tag }} - - steps: - - name: Checkout repository - uses: actions/checkout@v6 - - - name: Generate release notes from tag commit message - id: release_notes - shell: bash - run: | - set -euo pipefail - - # On tag pushes, GITHUB_SHA may be a tag object for annotated tags; - # peel it to the underlying commit. - commit="$(git rev-parse "${GITHUB_SHA}^{commit}")" - notes_path="${RUNNER_TEMP}/release-notes.md" - - # Use the commit message for the commit the tag points at (not the - # annotated tag message). - git log -1 --format=%B "${commit}" > "${notes_path}" - # Ensure trailing newline so GitHub's markdown renderer doesn't - # occasionally run the last line into subsequent content. - echo >> "${notes_path}" - - echo "path=${notes_path}" >> "${GITHUB_OUTPUT}" - - - uses: actions/download-artifact@v8 - with: - path: dist - - - name: List - run: ls -R dist/ - - - name: Delete entries from dist/ that should not go in the release - run: | - rm -rf dist/windows-binaries* - # cargo-timing.html appears under multiple target-specific directories. - # If included in files: dist/**, release upload races on duplicate - # asset names and can fail with 404s. - find dist -type f -name 'cargo-timing.html' -delete - find dist -type d -empty -delete - - ls -R dist/ - - - name: Add config schema release asset - run: | - cp codex-rs/core/config.schema.json dist/config-schema.json - - - name: Define release name - id: release_name - run: | - # Extract the version from the tag name, which is in the format - # "rust-v0.1.0". - version="${GITHUB_REF_NAME#rust-v}" - echo "name=${version}" >> $GITHUB_OUTPUT - - - name: Determine npm publish settings - id: npm_publish_settings - env: - VERSION: ${{ steps.release_name.outputs.name }} - run: | - set -euo pipefail - version="${VERSION}" - - if [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "should_publish=true" >> "$GITHUB_OUTPUT" - echo "npm_tag=" >> "$GITHUB_OUTPUT" - elif [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+-alpha\.[0-9]+$ ]]; then - echo "should_publish=true" >> "$GITHUB_OUTPUT" - echo "npm_tag=alpha" >> "$GITHUB_OUTPUT" - else - echo "should_publish=false" >> "$GITHUB_OUTPUT" - echo "npm_tag=" >> "$GITHUB_OUTPUT" - fi - - - name: Setup pnpm - uses: pnpm/action-setup@v5 - with: - run_install: false - - - name: Setup Node.js for npm packaging - uses: actions/setup-node@v6 - with: - node-version: 22 - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - # stage_npm_packages.py requires DotSlash when staging releases. - - uses: facebook/install-dotslash@v2 - - name: Stage npm packages - env: - GH_TOKEN: ${{ github.token }} - RELEASE_VERSION: ${{ steps.release_name.outputs.name }} - run: | - ./scripts/stage_npm_packages.py \ - --release-version "$RELEASE_VERSION" \ - --package codex \ - --package codex-responses-api-proxy \ - --package codex-sdk - - - name: Stage installer scripts - run: | - cp scripts/install/install.sh dist/install.sh - cp scripts/install/install.ps1 dist/install.ps1 - - - name: Create GitHub Release - uses: softprops/action-gh-release@v2 - with: - name: ${{ steps.release_name.outputs.name }} - tag_name: ${{ github.ref_name }} - body_path: ${{ steps.release_notes.outputs.path }} - files: dist/** - # Mark as prerelease only when the version has a suffix after x.y.z - # (e.g. -alpha, -beta). Otherwise publish a normal release. - prerelease: ${{ contains(steps.release_name.outputs.name, '-') }} - - - uses: facebook/dotslash-publish-release@v2 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - tag: ${{ github.ref_name }} - config: .github/dotslash-config.json - - - uses: facebook/dotslash-publish-release@v2 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - tag: ${{ github.ref_name }} - config: .github/dotslash-zsh-config.json - - - uses: facebook/dotslash-publish-release@v2 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - tag: ${{ github.ref_name }} - config: .github/dotslash-argument-comment-lint-config.json - - - name: Trigger developers.openai.com deploy - # Only trigger the deploy if the release is not a pre-release. - # The deploy is used to update the developers.openai.com website with the new config schema json file. - if: ${{ !contains(steps.release_name.outputs.name, '-') }} - continue-on-error: true - env: - DEV_WEBSITE_VERCEL_DEPLOY_HOOK_URL: ${{ secrets.DEV_WEBSITE_VERCEL_DEPLOY_HOOK_URL }} - run: | - if ! curl -sS -f -o /dev/null -X POST "$DEV_WEBSITE_VERCEL_DEPLOY_HOOK_URL"; then - echo "::warning title=developers.openai.com deploy hook failed::Vercel deploy hook POST failed for ${GITHUB_REF_NAME}" - exit 1 - fi - - # Publish to npm using OIDC authentication. - # July 31, 2025: https://github.blog/changelog/2025-07-31-npm-trusted-publishing-with-oidc-is-generally-available/ - # npm docs: https://docs.npmjs.com/trusted-publishers - publish-npm: - # Publish to npm for stable releases and alpha pre-releases with numeric suffixes. - if: ${{ needs.release.outputs.should_publish_npm == 'true' }} - name: publish-npm - needs: release - runs-on: ubuntu-latest - permissions: - id-token: write # Required for OIDC - contents: read - - steps: - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: 22 - registry-url: "https://registry.npmjs.org" - scope: "@openai" - - # Trusted publishing requires npm CLI version 11.5.1 or later. - - name: Update npm - run: npm install -g npm@latest - - - name: Download npm tarballs from release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RELEASE_TAG: ${{ needs.release.outputs.tag }} - RELEASE_VERSION: ${{ needs.release.outputs.version }} - run: | - set -euo pipefail - version="$RELEASE_VERSION" - tag="$RELEASE_TAG" - mkdir -p dist/npm - patterns=( - "codex-npm-${version}.tgz" - "codex-npm-linux-*-${version}.tgz" - "codex-npm-darwin-*-${version}.tgz" - "codex-npm-win32-*-${version}.tgz" - "codex-responses-api-proxy-npm-${version}.tgz" - "codex-sdk-npm-${version}.tgz" - ) - for pattern in "${patterns[@]}"; do - gh release download "$tag" \ - --repo "${GITHUB_REPOSITORY}" \ - --pattern "$pattern" \ - --dir dist/npm - done - - # No NODE_AUTH_TOKEN needed because we use OIDC. - - name: Publish to npm - env: - VERSION: ${{ needs.release.outputs.version }} - NPM_TAG: ${{ needs.release.outputs.npm_tag }} - run: | - set -euo pipefail - prefix="" - if [[ -n "${NPM_TAG}" ]]; then - prefix="${NPM_TAG}-" - fi - - shopt -s nullglob - tarballs=(dist/npm/*-"${VERSION}".tgz) - if [[ ${#tarballs[@]} -eq 0 ]]; then - echo "No npm tarballs found in dist/npm for version ${VERSION}" - exit 1 - fi - - for tarball in "${tarballs[@]}"; do - filename="$(basename "${tarball}")" - tag="" - - case "${filename}" in - codex-npm-linux-*-"${VERSION}".tgz|codex-npm-darwin-*-"${VERSION}".tgz|codex-npm-win32-*-"${VERSION}".tgz) - platform="${filename#codex-npm-}" - platform="${platform%-${VERSION}.tgz}" - tag="${prefix}${platform}" - ;; - codex-npm-"${VERSION}".tgz|codex-responses-api-proxy-npm-"${VERSION}".tgz|codex-sdk-npm-"${VERSION}".tgz) - tag="${NPM_TAG}" - ;; - *) - echo "Unexpected npm tarball: ${filename}" - exit 1 - ;; - esac - - publish_cmd=(npm publish "${GITHUB_WORKSPACE}/${tarball}") - if [[ -n "${tag}" ]]; then - publish_cmd+=(--tag "${tag}") - fi - - echo "+ ${publish_cmd[*]}" - set +e - publish_output="$("${publish_cmd[@]}" 2>&1)" - publish_status=$? - set -e - - echo "${publish_output}" - if [[ ${publish_status} -eq 0 ]]; then - continue - fi - - if grep -qiE "previously published|cannot publish over|version already exists" <<< "${publish_output}"; then - echo "Skipping already-published package version for ${filename}" - continue - fi - - exit "${publish_status}" - done - - winget: - name: winget - needs: release - # Only publish stable/mainline releases to WinGet; pre-releases include a - # '-' in the semver string (e.g., 1.2.3-alpha.1). - if: ${{ !contains(needs.release.outputs.version, '-') }} - # This job only invokes a GitHub Action to open/update the winget-pkgs PR; - # it does not execute Windows-only tooling, so Linux is sufficient. - runs-on: ubuntu-latest - permissions: - contents: read - - steps: - - name: Publish to WinGet - uses: vedantmgoyal9/winget-releaser@7bd472be23763def6e16bd06cc8b1cdfab0e2fd5 - with: - identifier: OpenAI.Codex - version: ${{ needs.release.outputs.version }} - release-tag: ${{ needs.release.outputs.tag }} - fork-user: openai-oss-forks - installers-regex: '^codex-(?:x86_64|aarch64)-pc-windows-msvc\.exe\.zip$' - token: ${{ secrets.WINGET_PUBLISH_PAT }} - - update-branch: - name: Update latest-alpha-cli branch - permissions: - contents: write - needs: release - runs-on: ubuntu-latest - - steps: - - name: Update latest-alpha-cli branch - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - set -euo pipefail - gh api \ - repos/${GITHUB_REPOSITORY}/git/refs/heads/latest-alpha-cli \ - -X PATCH \ - -f sha="${GITHUB_SHA}" \ - -F force=true diff --git a/.github/workflows/rusty-v8-release.yml b/.github/workflows/rusty-v8-release.yml deleted file mode 100644 index bb191b88c..000000000 --- a/.github/workflows/rusty-v8-release.yml +++ /dev/null @@ -1,188 +0,0 @@ -name: rusty-v8-release - -on: - workflow_dispatch: - inputs: - release_tag: - description: Optional release tag. Defaults to rusty-v8-v. - required: false - type: string - publish: - description: Publish the staged musl artifacts to a GitHub release. - required: false - default: true - type: boolean - -concurrency: - group: ${{ github.workflow }}::${{ inputs.release_tag || github.run_id }} - cancel-in-progress: false - -jobs: - metadata: - runs-on: ubuntu-latest - outputs: - release_tag: ${{ steps.release_tag.outputs.release_tag }} - v8_version: ${{ steps.v8_version.outputs.version }} - - steps: - - uses: actions/checkout@v6 - - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: "3.12" - - - name: Resolve exact v8 crate version - id: v8_version - shell: bash - run: | - set -euo pipefail - version="$(python3 .github/scripts/rusty_v8_bazel.py resolved-v8-crate-version)" - echo "version=${version}" >> "$GITHUB_OUTPUT" - - - name: Resolve release tag - id: release_tag - env: - RELEASE_TAG_INPUT: ${{ inputs.release_tag }} - V8_VERSION: ${{ steps.v8_version.outputs.version }} - shell: bash - run: | - set -euo pipefail - - release_tag="${RELEASE_TAG_INPUT}" - if [[ -z "${release_tag}" ]]; then - release_tag="rusty-v8-v${V8_VERSION}" - fi - - echo "release_tag=${release_tag}" >> "$GITHUB_OUTPUT" - - build: - name: Build ${{ matrix.target }} - needs: metadata - runs-on: ${{ matrix.runner }} - permissions: - contents: read - actions: read - strategy: - fail-fast: false - matrix: - include: - - runner: ubuntu-24.04 - platform: linux_amd64_musl - target: x86_64-unknown-linux-musl - - runner: ubuntu-24.04-arm - platform: linux_arm64_musl - target: aarch64-unknown-linux-musl - - steps: - - uses: actions/checkout@v6 - - - name: Set up Bazel - uses: bazelbuild/setup-bazelisk@v3 - - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: "3.12" - - - name: Build Bazel V8 release pair - env: - BUILDBUDDY_API_KEY: ${{ secrets.BUILDBUDDY_API_KEY }} - PLATFORM: ${{ matrix.platform }} - TARGET: ${{ matrix.target }} - shell: bash - run: | - set -euo pipefail - - target_suffix="${TARGET//-/_}" - pair_target="//third_party/v8:rusty_v8_release_pair_${target_suffix}" - extra_targets=() - if [[ "${TARGET}" == *-unknown-linux-musl ]]; then - extra_targets=( - "@llvm//runtimes/libcxx:libcxx.static" - "@llvm//runtimes/libcxx:libcxxabi.static" - ) - fi - - bazel_args=( - build - -c - opt - "--platforms=@llvm//platforms:${PLATFORM}" - "${pair_target}" - "${extra_targets[@]}" - --build_metadata=COMMIT_SHA=$(git rev-parse HEAD) - ) - - bazel \ - --noexperimental_remote_repo_contents_cache \ - --bazelrc=.github/workflows/v8-ci.bazelrc \ - "${bazel_args[@]}" \ - "--remote_header=x-buildbuddy-api-key=${BUILDBUDDY_API_KEY}" - - - name: Stage release pair - env: - PLATFORM: ${{ matrix.platform }} - TARGET: ${{ matrix.target }} - shell: bash - run: | - set -euo pipefail - - python3 .github/scripts/rusty_v8_bazel.py stage-release-pair \ - --platform "${PLATFORM}" \ - --target "${TARGET}" \ - --compilation-mode opt \ - --output-dir "dist/${TARGET}" - - - name: Upload staged musl artifacts - uses: actions/upload-artifact@v7 - with: - name: rusty-v8-${{ needs.metadata.outputs.v8_version }}-${{ matrix.target }} - path: dist/${{ matrix.target }}/* - - publish-release: - if: ${{ inputs.publish }} - needs: - - metadata - - build - runs-on: ubuntu-latest - permissions: - contents: write - actions: read - - steps: - - name: Ensure publishing from default branch - if: ${{ github.ref_name != github.event.repository.default_branch }} - env: - DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} - shell: bash - run: | - set -euo pipefail - echo "Publishing is only allowed from ${DEFAULT_BRANCH}; current ref is ${GITHUB_REF_NAME}." >&2 - exit 1 - - - name: Ensure release tag is new - env: - GH_TOKEN: ${{ github.token }} - RELEASE_TAG: ${{ needs.metadata.outputs.release_tag }} - shell: bash - run: | - set -euo pipefail - - if gh release view "${RELEASE_TAG}" --repo "${GITHUB_REPOSITORY}" > /dev/null 2>&1; then - echo "Release tag ${RELEASE_TAG} already exists; musl artifact tags are immutable." >&2 - exit 1 - fi - - - uses: actions/download-artifact@v8 - with: - path: dist - - - name: Create GitHub Release - uses: softprops/action-gh-release@v2 - with: - tag_name: ${{ needs.metadata.outputs.release_tag }} - name: ${{ needs.metadata.outputs.release_tag }} - files: dist/** - # Keep V8 artifact releases out of Codex's normal "latest release" channel. - prerelease: true diff --git a/.github/workflows/sdk.yml b/.github/workflows/sdk.yml deleted file mode 100644 index c5026fe8c..000000000 --- a/.github/workflows/sdk.yml +++ /dev/null @@ -1,52 +0,0 @@ -name: sdk - -on: - push: - branches: [main] - pull_request: {} - -jobs: - sdks: - runs-on: - group: codex-runners - labels: codex-linux-x64 - timeout-minutes: 10 - steps: - - name: Checkout repository - uses: actions/checkout@v6 - - - name: Install Linux bwrap build dependencies - shell: bash - run: | - set -euo pipefail - sudo apt-get update -y - sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends pkg-config libcap-dev - - - name: Setup pnpm - uses: pnpm/action-setup@v5 - with: - run_install: false - - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: 22 - cache: pnpm - - - uses: dtolnay/rust-toolchain@1.93.0 - - - name: build codex - run: cargo build --bin codex - working-directory: codex-rs - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Build SDK packages - run: pnpm -r --filter ./sdk/typescript run build - - - name: Lint SDK packages - run: pnpm -r --filter ./sdk/typescript run lint - - - name: Test SDK packages - run: pnpm -r --filter ./sdk/typescript run test diff --git a/.github/workflows/v8-canary.yml b/.github/workflows/v8-canary.yml deleted file mode 100644 index 213c6a7b6..000000000 --- a/.github/workflows/v8-canary.yml +++ /dev/null @@ -1,132 +0,0 @@ -name: v8-canary - -on: - pull_request: - paths: - - ".github/scripts/rusty_v8_bazel.py" - - ".github/workflows/rusty-v8-release.yml" - - ".github/workflows/v8-canary.yml" - - "MODULE.bazel" - - "MODULE.bazel.lock" - - "codex-rs/Cargo.toml" - - "patches/BUILD.bazel" - - "patches/v8_*.patch" - - "third_party/v8/**" - push: - branches: - - main - paths: - - ".github/scripts/rusty_v8_bazel.py" - - ".github/workflows/rusty-v8-release.yml" - - ".github/workflows/v8-canary.yml" - - "MODULE.bazel" - - "MODULE.bazel.lock" - - "codex-rs/Cargo.toml" - - "patches/BUILD.bazel" - - "patches/v8_*.patch" - - "third_party/v8/**" - workflow_dispatch: - -concurrency: - group: ${{ github.workflow }}::${{ github.event.pull_request.number > 0 && format('pr-{0}', github.event.pull_request.number) || github.ref_name }} - cancel-in-progress: ${{ github.ref_name != 'main' }} - -jobs: - metadata: - runs-on: ubuntu-latest - outputs: - v8_version: ${{ steps.v8_version.outputs.version }} - - steps: - - uses: actions/checkout@v6 - - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: "3.12" - - - name: Resolve exact v8 crate version - id: v8_version - shell: bash - run: | - set -euo pipefail - version="$(python3 .github/scripts/rusty_v8_bazel.py resolved-v8-crate-version)" - echo "version=${version}" >> "$GITHUB_OUTPUT" - - build: - name: Build ${{ matrix.target }} - needs: metadata - runs-on: ${{ matrix.runner }} - permissions: - contents: read - actions: read - strategy: - fail-fast: false - matrix: - include: - - runner: ubuntu-24.04 - platform: linux_amd64_musl - target: x86_64-unknown-linux-musl - - runner: ubuntu-24.04-arm - platform: linux_arm64_musl - target: aarch64-unknown-linux-musl - - steps: - - uses: actions/checkout@v6 - - - name: Set up Bazel - uses: bazelbuild/setup-bazelisk@v3 - - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: "3.12" - - - name: Build Bazel V8 release pair - env: - BUILDBUDDY_API_KEY: ${{ secrets.BUILDBUDDY_API_KEY }} - PLATFORM: ${{ matrix.platform }} - TARGET: ${{ matrix.target }} - shell: bash - run: | - set -euo pipefail - - target_suffix="${TARGET//-/_}" - pair_target="//third_party/v8:rusty_v8_release_pair_${target_suffix}" - extra_targets=( - "@llvm//runtimes/libcxx:libcxx.static" - "@llvm//runtimes/libcxx:libcxxabi.static" - ) - - bazel_args=( - build - "--platforms=@llvm//platforms:${PLATFORM}" - "${pair_target}" - "${extra_targets[@]}" - --build_metadata=COMMIT_SHA=$(git rev-parse HEAD) - ) - - bazel \ - --noexperimental_remote_repo_contents_cache \ - --bazelrc=.github/workflows/v8-ci.bazelrc \ - "${bazel_args[@]}" \ - "--remote_header=x-buildbuddy-api-key=${BUILDBUDDY_API_KEY}" - - - name: Stage release pair - env: - PLATFORM: ${{ matrix.platform }} - TARGET: ${{ matrix.target }} - shell: bash - run: | - set -euo pipefail - - python3 .github/scripts/rusty_v8_bazel.py stage-release-pair \ - --platform "${PLATFORM}" \ - --target "${TARGET}" \ - --output-dir "dist/${TARGET}" - - - name: Upload staged musl artifacts - uses: actions/upload-artifact@v7 - with: - name: v8-canary-${{ needs.metadata.outputs.v8_version }}-${{ matrix.target }} - path: dist/${{ matrix.target }}/* diff --git a/codex-rs/vendor/bubblewrap/.github/workflows/check.yml b/codex-rs/vendor/bubblewrap/.github/workflows/check.yml deleted file mode 100644 index 8a747d52c..000000000 --- a/codex-rs/vendor/bubblewrap/.github/workflows/check.yml +++ /dev/null @@ -1,105 +0,0 @@ -name: CI checks - -on: - push: - branches: - - main - pull_request: - branches: - - main - -jobs: - meson: - name: Build with Meson and gcc, and test - runs-on: ubuntu-latest - steps: - - name: Check out - uses: actions/checkout@v4 - - name: Install build-dependencies - run: sudo ./ci/builddeps.sh - - name: Enable user namespaces - run: sudo ./ci/enable-userns.sh - - name: Create logs dir - run: mkdir test-logs - - name: setup - run: | - meson _build - env: - CFLAGS: >- - -O2 - -Wp,-D_FORTIFY_SOURCE=2 - -fsanitize=address - -fsanitize=undefined - - name: compile - run: ninja -C _build -v - - name: smoke-test - run: | - set -x - ./_build/bwrap --bind / / --tmpfs /tmp true - env: - ASAN_OPTIONS: detect_leaks=0 - - name: test - run: | - BWRAP_MUST_WORK=1 meson test -C _build - env: - ASAN_OPTIONS: detect_leaks=0 - - name: Collect overall test logs on failure - if: failure() - run: mv _build/meson-logs/testlog.txt test-logs/ || true - - name: install - run: | - DESTDIR="$(pwd)/DESTDIR" meson install -C _build - ( cd DESTDIR && find -ls ) - - name: dist - run: | - BWRAP_MUST_WORK=1 meson dist -C _build - - name: Collect dist test logs on failure - if: failure() - run: mv _build/meson-private/dist-build/meson-logs/testlog.txt test-logs/disttestlog.txt || true - - name: use as subproject - run: | - mkdir tests/use-as-subproject/subprojects - tar -C tests/use-as-subproject/subprojects -xf _build/meson-dist/bubblewrap-*.tar.xz - mv tests/use-as-subproject/subprojects/bubblewrap-* tests/use-as-subproject/subprojects/bubblewrap - ( cd tests/use-as-subproject && meson _build ) - ninja -C tests/use-as-subproject/_build -v - meson test -C tests/use-as-subproject/_build - DESTDIR="$(pwd)/DESTDIR-as-subproject" meson install -C tests/use-as-subproject/_build - ( cd DESTDIR-as-subproject && find -ls ) - test -x DESTDIR-as-subproject/usr/local/libexec/not-flatpak-bwrap - test ! -e DESTDIR-as-subproject/usr/local/bin/bwrap - test ! -e DESTDIR-as-subproject/usr/local/libexec/bwrap - tests/use-as-subproject/assert-correct-rpath.py DESTDIR-as-subproject/usr/local/libexec/not-flatpak-bwrap - - name: Upload test logs - uses: actions/upload-artifact@v4 - if: failure() || cancelled() - with: - name: test logs - path: test-logs - - clang: - name: Build with clang and analyze - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - language: - - cpp - steps: - - name: Initialize CodeQL - uses: github/codeql-action/init@v2 - with: - languages: ${{ matrix.language }} - - name: Check out - uses: actions/checkout@v4 - - name: Install build-dependencies - run: sudo ./ci/builddeps.sh --clang - - run: meson build -Dselinux=enabled - env: - CC: clang - CFLAGS: >- - -O2 - -Werror=unused-variable - - run: meson compile -C build - - name: CodeQL analysis - uses: github/codeql-action/analyze@v2 diff --git a/scripts/stage_npm_packages.py b/scripts/stage_npm_packages.py index 5bbee755e..0c5c4d51b 100755 --- a/scripts/stage_npm_packages.py +++ b/scripts/stage_npm_packages.py @@ -16,7 +16,7 @@ REPO_ROOT = Path(__file__).resolve().parent.parent BUILD_SCRIPT = REPO_ROOT / "codex-cli" / "scripts" / "build_npm_package.py" INSTALL_NATIVE_DEPS = REPO_ROOT / "codex-cli" / "scripts" / "install_native_deps.py" -WORKFLOW_NAME = ".github/workflows/rust-release.yml" +DEFAULT_WORKFLOW_NAME = ".github/workflows/rust-release.yml" GITHUB_REPO = "openai/codex" _SPEC = importlib.util.spec_from_file_location("codex_build_npm_package", BUILD_SCRIPT) @@ -78,7 +78,7 @@ def expand_packages(packages: list[str]) -> list[str]: return expanded -def resolve_release_workflow(version: str) -> dict: +def resolve_release_workflow(version: str, workflow_name: str) -> dict: stdout = subprocess.check_output( [ "gh", @@ -89,7 +89,7 @@ def resolve_release_workflow(version: str) -> dict: "--json", "workflowName,url,headSha", "--workflow", - WORKFLOW_NAME, + workflow_name, "--jq", "first(.[])", ], @@ -98,7 +98,12 @@ def resolve_release_workflow(version: str) -> dict: ) workflow = json.loads(stdout or "null") if not workflow: - raise RuntimeError(f"Unable to find rust-release workflow for version {version}.") + raise RuntimeError( + "Unable to find a release workflow run for version " + f"{version} using {workflow_name}. " + "If this fork does not keep the upstream release workflows, pass " + "--workflow-url explicitly." + ) return workflow @@ -106,7 +111,8 @@ def resolve_workflow_url(version: str, override: str | None) -> tuple[str, str | if override: return override, None - workflow = resolve_release_workflow(version) + workflow_name = DEFAULT_WORKFLOW_NAME + workflow = resolve_release_workflow(version, workflow_name) return workflow["url"], workflow.get("headSha") diff --git a/third_party/v8/README.md b/third_party/v8/README.md index 9ad37c6f0..9f1f51a9a 100644 --- a/third_party/v8/README.md +++ b/third_party/v8/README.md @@ -30,7 +30,9 @@ with these raw asset names: - `librusty_v8_release_.a.gz` - `src_binding_release_.rs` -The dedicated publishing workflow is `.github/workflows/rusty-v8-release.yml`. +In the upstream repository, the dedicated publishing workflow is +.github/workflows/rusty-v8-release.yml. +Private forks may remove that workflow and stage artifacts by other means. It builds musl release pairs from source and keeps the release artifacts as the statically linked form: From 063e8ea8195547eb93b5956459b29aa0e5a4adc7 Mon Sep 17 00:00:00 2001 From: Eric Juta Date: Sun, 29 Mar 2026 23:42:36 +0100 Subject: [PATCH 33/45] Add agentmemory runtime recall surface - expose an assistant-facing memory_recall tool behind the agentmemory backend gate - make /memory-* commands visibly acknowledge submission and results in both TUIs - document the canonical runtime surface for the fork Co-authored-by: Codex --- codex-rs/core/src/agentmemory/mod.rs | 88 ++++- codex-rs/core/src/codex.rs | 38 ++- .../core/src/tools/handlers/memory_recall.rs | 83 +++++ codex-rs/core/src/tools/handlers/mod.rs | 2 + codex-rs/core/src/tools/spec.rs | 66 ++++ codex-rs/core/src/tools/spec_tests.rs | 51 ++- .../docs/agentmemory_runtime_surface_spec.md | 315 ++++++++++++++++++ codex-rs/tui/src/chatwidget.rs | 44 +++ codex-rs/tui/src/chatwidget/tests.rs | 131 +++++++- codex-rs/tui_app_server/src/chatwidget.rs | 46 ++- .../tui_app_server/src/chatwidget/tests.rs | 97 ++++++ 11 files changed, 939 insertions(+), 22 deletions(-) create mode 100644 codex-rs/core/src/tools/handlers/memory_recall.rs create mode 100644 codex-rs/docs/agentmemory_runtime_surface_spec.md diff --git a/codex-rs/core/src/agentmemory/mod.rs b/codex-rs/core/src/agentmemory/mod.rs index 65ddc3836..fd5971dc5 100644 --- a/codex-rs/core/src/agentmemory/mod.rs +++ b/codex-rs/core/src/agentmemory/mod.rs @@ -3,6 +3,7 @@ //! This module provides the seam for integrating the `agentmemory` service //! as a replacement for Codex's native memory engine. +use serde::Serialize; use serde_json::json; use std::path::Path; use std::sync::OnceLock; @@ -17,6 +18,14 @@ pub struct AgentmemoryAdapter { /// Reusing the client allows connection pooling (keep-alive) for high throughput. static CLIENT: OnceLock = OnceLock::new(); +pub(crate) const DEFAULT_RUNTIME_RECALL_TOKEN_BUDGET: usize = 2_000; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub(crate) struct MemoryRecallResult { + pub(crate) recalled: bool, + pub(crate) context: String, +} + fn get_client() -> &'static reqwest::Client { CLIENT.get_or_init(|| reqwest::Client::builder().build().unwrap_or_default()) } @@ -65,8 +74,9 @@ impl AgentmemoryAdapter { let context_result = client.post(&url).json(&request_body).send().await; let mut instructions = - "Use the `AgentMemory` tools to search and retrieve relevant memory.\n\ - Your context is bounded; use targeted queries to expand details as needed." + "Use the `memory_recall` tool to retrieve relevant historical context when needed.\n\ + Agentmemory startup context may be attached below when available.\n\ + Your context is bounded; prefer targeted recall queries over broad memory fetches." .to_string(); if let Ok(res) = context_result @@ -378,6 +388,27 @@ impl AgentmemoryAdapter { .to_string()) } + pub(crate) async fn recall_for_runtime( + &self, + session_id: &str, + project: &Path, + query: Option<&str>, + ) -> Result { + let context = self + .recall_context( + session_id, + project, + query, + DEFAULT_RUNTIME_RECALL_TOKEN_BUDGET, + ) + .await?; + + Ok(MemoryRecallResult { + recalled: !context.trim().is_empty(), + context, + }) + } + /// Registers a session so Agentmemory's session-backed views can discover it. pub async fn start_session( &self, @@ -449,6 +480,7 @@ impl AgentmemoryAdapter { } } #[cfg(test)] +#[allow(clippy::await_holding_lock)] mod tests { use super::*; use serde_json::json; @@ -499,6 +531,58 @@ mod tests { } } + #[tokio::test] + #[serial_test::serial(agentmemory_env)] + async fn test_startup_instructions_describe_current_runtime_surface() { + let server = MockServer::start().await; + let adapter = AgentmemoryAdapter::new(); + let _guard = ENV_LOCK.lock().expect("lock env"); + let _agentmemory_url_guard = EnvVarGuard::set("AGENTMEMORY_URL", server.uri().as_str()); + + Mock::given(method("POST")) + .and(path("/agentmemory/context")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "context": "" + }))) + .expect(1) + .mount(&server) + .await; + + let instructions = adapter + .build_startup_developer_instructions(Path::new("/tmp/project"), 256) + .await + .expect("instructions should be returned"); + + assert!(instructions.contains("Use the `memory_recall` tool")); + assert!(instructions.contains("Agentmemory startup context may be attached below")); + assert!(instructions.contains("prefer targeted recall queries over broad memory fetches")); + } + + #[tokio::test] + #[serial_test::serial(agentmemory_env)] + async fn test_startup_instructions_append_retrieved_context() { + let server = MockServer::start().await; + let adapter = AgentmemoryAdapter::new(); + let _guard = ENV_LOCK.lock().expect("lock env"); + let _agentmemory_url_guard = EnvVarGuard::set("AGENTMEMORY_URL", server.uri().as_str()); + + Mock::given(method("POST")) + .and(path("/agentmemory/context")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "context": "remember this" + }))) + .expect(1) + .mount(&server) + .await; + + let instructions = adapter + .build_startup_developer_instructions(Path::new("/tmp/project"), 256) + .await + .expect("instructions should be returned"); + + assert!(instructions.contains("remember this")); + } + #[test] fn test_format_agentmemory_payload_maps_prompt_submit_shape() { let adapter = AgentmemoryAdapter::new(); diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index d8dd010d3..746a44411 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -937,6 +937,7 @@ impl TurnContext { .with_unified_exec_shell_mode(self.tools_config.unified_exec_shell_mode.clone()) .with_web_search_config(self.tools_config.web_search_config.clone()) .with_allow_login_shell(self.tools_config.allow_login_shell) + .with_memory_backend(config.memories.backend.clone()) .with_agent_roles(config.agent_roles.clone()); Self { @@ -1421,6 +1422,7 @@ impl Session { ) .with_web_search_config(per_turn_config.web_search_config.clone()) .with_allow_login_shell(per_turn_config.permissions.allow_login_shell) + .with_memory_backend(per_turn_config.memories.backend.clone()) .with_agent_roles(per_turn_config.agent_roles.clone()); let cwd = session_configuration.cwd.clone(); @@ -5165,7 +5167,7 @@ mod handlers { id: sub_id, msg: EventMsg::Warning(WarningEvent { message: format!( - "Dropped memories at {} and cleared memory rows from state db.", + "Memory drop completed. Cleared memory rows from the state db and removed stored memory files at {}.", memory_root.display() ), }), @@ -5206,7 +5208,7 @@ mod handlers { sess.send_event_raw(Event { id: sub_id.clone(), msg: EventMsg::Warning(WarningEvent { - message: "Agentmemory sync triggered.".to_string(), + message: "Agentmemory sync triggered. Updated observations will appear in future memory recalls once consolidation completes.".to_string(), }), }) .await; @@ -5220,7 +5222,8 @@ mod handlers { sess.send_event_raw(Event { id: sub_id.clone(), msg: EventMsg::Warning(WarningEvent { - message: "Memory update triggered.".to_string(), + message: "Memory update triggered. Consolidation is running in the background." + .to_string(), }), }) .await; @@ -5247,15 +5250,11 @@ mod handlers { let session_id = sess.conversation_id.to_string(); match adapter - .recall_context( - &session_id, - config.cwd.as_ref(), - query.as_deref(), - /*token_budget*/ 2000, - ) + .recall_for_runtime(&session_id, config.cwd.as_ref(), query.as_deref()) .await { - Ok(context) if !context.trim().is_empty() => { + Ok(result) if result.recalled => { + let context = result.context; let turn_context = sess.new_default_turn_with_sub_id(sub_id.clone()).await; let message: ResponseItem = DeveloperInstructions::new(format!( "\n{context}\n" @@ -5263,19 +5262,33 @@ mod handlers { .into(); sess.record_conversation_items(&turn_context, std::slice::from_ref(&message)) .await; + let query_summary = query + .as_deref() + .map(str::trim) + .filter(|query| !query.is_empty()) + .map(|query| format!(" for query: {query}")) + .unwrap_or_default(); sess.send_event_raw(Event { id: sub_id, msg: EventMsg::Warning(WarningEvent { - message: "Memory context recalled and injected.".to_string(), + message: format!( + "Memory context recalled{query_summary} and injected into this thread:\n\n{context}" + ), }), }) .await; } Ok(_) => { + let query_summary = query + .as_deref() + .map(str::trim) + .filter(|query| !query.is_empty()) + .map(|query| format!(" for query: {query}")) + .unwrap_or_default(); sess.send_event_raw(Event { id: sub_id, msg: EventMsg::Warning(WarningEvent { - message: "No relevant memory context found.".to_string(), + message: format!("No relevant memory context found{query_summary}."), }), }) .await; @@ -5589,6 +5602,7 @@ async fn spawn_review_thread( sess.services.main_execve_wrapper_exe.as_ref(), ) .with_web_search_config(/*web_search_config*/ None) + .with_memory_backend(config.memories.backend.clone()) .with_allow_login_shell(config.permissions.allow_login_shell) .with_agent_roles(config.agent_roles.clone()); diff --git a/codex-rs/core/src/tools/handlers/memory_recall.rs b/codex-rs/core/src/tools/handlers/memory_recall.rs new file mode 100644 index 000000000..9327b236d --- /dev/null +++ b/codex-rs/core/src/tools/handlers/memory_recall.rs @@ -0,0 +1,83 @@ +use async_trait::async_trait; +use serde::Deserialize; + +use crate::config::types::MemoryBackend; +use crate::function_tool::FunctionCallError; +use crate::tools::context::FunctionToolOutput; +use crate::tools::context::ToolInvocation; +use crate::tools::context::ToolPayload; +use crate::tools::handlers::parse_arguments; +use crate::tools::registry::ToolHandler; +use crate::tools::registry::ToolKind; + +#[derive(Debug, Deserialize)] +struct MemoryRecallArgs { + #[serde(default)] + query: Option, +} + +pub struct MemoryRecallHandler; + +#[async_trait] +impl ToolHandler for MemoryRecallHandler { + type Output = FunctionToolOutput; + + fn kind(&self) -> ToolKind { + ToolKind::Function + } + + async fn handle(&self, invocation: ToolInvocation) -> Result { + let ToolInvocation { + session, + turn, + payload, + .. + } = invocation; + + let arguments = match payload { + ToolPayload::Function { arguments } => arguments, + _ => { + return Err(FunctionCallError::RespondToModel( + "memory_recall handler received unsupported payload".to_string(), + )); + } + }; + + if turn.config.memories.backend != MemoryBackend::Agentmemory { + return Err(FunctionCallError::RespondToModel( + "memory_recall requires agentmemory backend".to_string(), + )); + } + + let args: MemoryRecallArgs = parse_arguments(&arguments)?; + let query = args + .query + .as_deref() + .map(str::trim) + .filter(|query| !query.is_empty()); + + let adapter = crate::agentmemory::AgentmemoryAdapter::new(); + let response = adapter + .recall_for_runtime( + &session.conversation_id.to_string(), + turn.cwd.as_path(), + query, + ) + .await + .map_err(|err| { + FunctionCallError::RespondToModel(format!("memory_recall failed: {err}")) + })?; + + let content = serde_json::to_string(&response).map_err(|err| { + FunctionCallError::Fatal(format!("failed to serialize memory_recall response: {err}")) + })?; + + let mut output = FunctionToolOutput::from_text(content, Some(true)); + output.post_tool_use_response = Some(serde_json::to_value(&response).map_err(|err| { + FunctionCallError::Fatal(format!( + "failed to encode memory_recall post-tool response: {err}" + )) + })?); + Ok(output) + } +} diff --git a/codex-rs/core/src/tools/handlers/mod.rs b/codex-rs/core/src/tools/handlers/mod.rs index 3241b323b..612bba143 100644 --- a/codex-rs/core/src/tools/handlers/mod.rs +++ b/codex-rs/core/src/tools/handlers/mod.rs @@ -5,6 +5,7 @@ mod js_repl; mod list_dir; mod mcp; mod mcp_resource; +mod memory_recall; pub(crate) mod multi_agents; pub(crate) mod multi_agents_common; pub(crate) mod multi_agents_v2; @@ -42,6 +43,7 @@ pub use js_repl::JsReplResetHandler; pub use list_dir::ListDirHandler; pub use mcp::McpHandler; pub use mcp_resource::McpResourceHandler; +pub use memory_recall::MemoryRecallHandler; pub use plan::PlanHandler; pub use request_permissions::RequestPermissionsHandler; pub(crate) use request_permissions::request_permissions_tool_description; diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index 7c8fb0d9b..840aa5b54 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -3,6 +3,7 @@ use crate::client_common::tools::FreeformToolFormat; use crate::client_common::tools::ResponsesApiTool; use crate::client_common::tools::ToolSpec; use crate::config::AgentRoleConfig; +use crate::config::types::MemoryBackend; use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; use crate::mcp_connection_manager::ToolInfo; use crate::models_manager::collaboration_mode_presets::CollaborationModesConfig; @@ -167,6 +168,24 @@ fn send_input_output_schema() -> JsonValue { }) } +fn memory_recall_output_schema() -> JsonValue { + json!({ + "type": "object", + "properties": { + "recalled": { + "type": "boolean", + "description": "Whether agentmemory returned any context for this request." + }, + "context": { + "type": "string", + "description": "Recalled memory context. Empty when nothing relevant was found." + } + }, + "required": ["recalled", "context"], + "additionalProperties": false + }) +} + fn list_agents_output_schema() -> JsonValue { json!({ "type": "object", @@ -313,6 +332,8 @@ impl UnifiedExecShellMode { #[derive(Debug, Clone)] pub(crate) struct ToolsConfig { pub available_models: Vec, + pub memory_backend: MemoryBackend, + pub memory_tool_enabled: bool, pub shell_type: ConfigShellToolType, shell_command_backend: ShellCommandBackendConfig, pub unified_exec_shell_mode: UnifiedExecShellMode, @@ -447,6 +468,8 @@ impl ToolsConfig { Self { available_models: available_models_ref.to_vec(), + memory_backend: MemoryBackend::Native, + memory_tool_enabled: features.enabled(Feature::MemoryTool), shell_type, shell_command_backend, unified_exec_shell_mode: UnifiedExecShellMode::Direct, @@ -481,6 +504,11 @@ impl ToolsConfig { self } + pub fn with_memory_backend(mut self, memory_backend: MemoryBackend) -> Self { + self.memory_backend = memory_backend; + self + } + pub fn with_allow_login_shell(mut self, allow_login_shell: bool) -> Self { self.allow_login_shell = allow_login_shell; self @@ -1740,6 +1768,32 @@ fn create_request_permissions_tool() -> ToolSpec { }) } +fn create_memory_recall_tool() -> ToolSpec { + let properties = BTreeMap::from([( + "query".to_string(), + JsonSchema::String { + description: Some( + "Optional targeted memory recall query. When omitted, recall uses the current thread and project context only." + .to_string(), + ), + }, + )]); + + ToolSpec::Function(ResponsesApiTool { + name: "memory_recall".to_string(), + description: "Recall relevant agentmemory context for the current thread and project. Use this when historical context is needed and live memory is available in the current runtime." + .to_string(), + strict: false, + defer_loading: None, + parameters: JsonSchema::Object { + properties, + required: None, + additional_properties: Some(false.into()), + }, + output_schema: Some(memory_recall_output_schema()), + }) +} + fn create_close_agent_tool() -> ToolSpec { let mut properties = BTreeMap::new(); properties.insert( @@ -2565,6 +2619,7 @@ pub(crate) fn build_specs_with_discoverable_tools( use crate::tools::handlers::ListDirHandler; use crate::tools::handlers::McpHandler; use crate::tools::handlers::McpResourceHandler; + use crate::tools::handlers::MemoryRecallHandler; use crate::tools::handlers::PlanHandler; use crate::tools::handlers::RequestPermissionsHandler; use crate::tools::handlers::RequestUserInputHandler; @@ -2602,6 +2657,7 @@ pub(crate) fn build_specs_with_discoverable_tools( let request_user_input_handler = Arc::new(RequestUserInputHandler { default_mode_request_user_input: config.default_mode_request_user_input, }); + let memory_recall_handler = Arc::new(MemoryRecallHandler); let tool_suggest_handler = Arc::new(ToolSuggestHandler); let code_mode_handler = Arc::new(CodeModeExecuteHandler); let code_mode_wait_handler = Arc::new(CodeModeWaitHandler); @@ -2781,6 +2837,16 @@ pub(crate) fn build_specs_with_discoverable_tools( builder.register_handler("request_permissions", request_permissions_handler); } + if config.memory_tool_enabled && config.memory_backend == MemoryBackend::Agentmemory { + push_tool_spec( + &mut builder, + create_memory_recall_tool(), + /*supports_parallel_tool_calls*/ false, + config.code_mode_enabled, + ); + builder.register_handler("memory_recall", memory_recall_handler); + } + if config.search_tool && let Some(app_tools) = app_tools { diff --git a/codex-rs/core/src/tools/spec_tests.rs b/codex-rs/core/src/tools/spec_tests.rs index 0cd8c2e1f..78f696d93 100644 --- a/codex-rs/core/src/tools/spec_tests.rs +++ b/codex-rs/core/src/tools/spec_tests.rs @@ -1,5 +1,6 @@ use crate::client_common::tools::FreeformTool; use crate::config::test_config; +use crate::config::types::MemoryBackend; use crate::models_manager::manager::ModelsManager; use crate::models_manager::model_info::with_config_overrides; use crate::shell::Shell; @@ -914,7 +915,7 @@ fn request_permissions_tool_is_independent_from_additional_permissions() { } #[test] -fn get_memory_requires_feature_flag() { +fn memory_recall_requires_feature_flag() { let config = test_config(); let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); let mut features = Features::with_defaults(); @@ -930,10 +931,50 @@ fn get_memory_requires_feature_flag() { windows_sandbox_level: WindowsSandboxLevel::Disabled, }); let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); - assert!( - !tools.iter().any(|t| t.spec.name() == "get_memory"), - "get_memory should be disabled when memory_tool feature is off" - ); + assert_lacks_tool_name(&tools, "memory_recall"); +} + +#[test] +fn memory_recall_is_absent_with_memory_tool_feature_on_and_native_backend() { + let config = test_config(); + let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); + let mut features = Features::with_defaults(); + features.enable(Feature::MemoryTool); + let available_models = Vec::new(); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + sandbox_policy: &SandboxPolicy::DangerFullAccess, + windows_sandbox_level: WindowsSandboxLevel::Disabled, + }); + let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); + assert_lacks_tool_name(&tools, "memory_recall"); +} + +#[test] +fn memory_recall_is_exposed_with_memory_tool_feature_on_and_agentmemory_backend() { + let config = test_config(); + let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); + let mut features = Features::with_defaults(); + features.enable(Feature::MemoryTool); + let available_models = Vec::new(); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + sandbox_policy: &SandboxPolicy::DangerFullAccess, + windows_sandbox_level: WindowsSandboxLevel::Disabled, + }) + .with_memory_backend(MemoryBackend::Agentmemory); + let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); + + let memory_recall_tool = find_tool(&tools, "memory_recall"); + assert_eq!(memory_recall_tool.spec, create_memory_recall_tool()); } #[test] diff --git a/codex-rs/docs/agentmemory_runtime_surface_spec.md b/codex-rs/docs/agentmemory_runtime_surface_spec.md new file mode 100644 index 000000000..deb089355 --- /dev/null +++ b/codex-rs/docs/agentmemory_runtime_surface_spec.md @@ -0,0 +1,315 @@ +# Agentmemory Runtime Surface Spec + +## Status + +Proposed implementation handoff for the runtime-surface lane. + +This document is intentionally narrow. It is the canonical handoff for how +`agentmemory` should appear at runtime in this fork. + +It does not replace the broader architecture decisions in: + +- `docs/agentmemory_payload_quality_spec.md` +- `/Users/ericjuta/Projects/codex/docs/agentmemory-codex-memory-replacement-spec.md` + +Those documents answer whether `agentmemory` should be the primary memory +engine and how capture/retrieval quality should work. This document answers the +next question: + +- what human-facing and assistant-facing runtime surfaces should exist, +- what they should call internally, +- what should be visible in the TUI, +- what should not be built. + +## Goal + +Present one coherent memory system to both: + +- the human user in the TUI, and +- the assistant at runtime. + +The design must avoid hidden memory mutations, duplicate retrieval paths, and +assistant confusion about whether memory is actually available. + +## Current Problem + +The fork currently has an awkward split: + +- the human can explicitly trigger `/memory-recall`, `/memory-update`, and + `/memory-drop` from the TUI, +- core already knows how to call the `agentmemory` adapter, +- the assistant often cannot see an equivalent first-class callable memory + surface, +- successful recall can inject developer instructions into the thread without + giving the human a strong visible explanation of what happened. + +That shape is product-incoherent. It causes both: + +- user confusion: "I pressed Enter and nothing happened" +- assistant confusion: "memory is not available here" even though the human + command exists and the backend is healthy + +## Decision + +The correct runtime design for this fork is: + +1. `agentmemory` is the one authoritative runtime memory backend. +2. There is one canonical core recall/update/drop implementation. +3. The human gets an explicit TUI slash-command control plane. +4. The assistant gets a first-class internal recall tool. +5. Both surfaces reuse the same core semantics and backend adapter. +6. MCP is not part of this lane. + +## Canonical Core Path + +All runtime memory retrieval in this fork should route through one shared core +path backed by `AgentmemoryAdapter`. + +Minimum shared inputs: + +- `session_id` +- `project` / `cwd` +- `query: Option` +- internal token budget + +Current relevant implementation points: + +- adapter transport and endpoint selection: + - `core/src/agentmemory/mod.rs` +- current slash-command recall implementation: + - `core/src/codex.rs` +- current TUI slash-command dispatch: + - `tui/src/chatwidget.rs` + - `tui_app_server/src/chatwidget.rs` + +Design rule: + +- do not create separate retrieval implementations for: + - slash-command recall + - assistant-facing recall tool + - startup retrieval + +Instead, create or retain one small shared core helper and have all public +surfaces call that helper. + +## Runtime Surfaces + +### Human Surface + +Keep these slash commands: + +- `/memory-recall [query]` +- `/memory-update` +- `/memory-drop` + +They remain explicit human controls. + +Required UX behavior: + +- on submit: + - show immediate local feedback in history so the UI never feels inert +- on recall success: + - show that memory was recalled + - show the recalled context itself, or a faithful preview of it + - make it clear that the context was injected into the active thread +- on recall empty result: + - show an explicit "no relevant memory context found" message +- on recall error: + - show an error event +- on update/drop success: + - show a concrete completion message, not only a vague "triggered" message +- on recall without a thread: + - show an explicit thread/session requirement message + +Human-surface principle: + +- memory actions must be observable by the human, not only by the assistant. + +### Assistant Surface + +Add one first-class internal tool for recall. + +Recommended initial tool: + +- `memory_recall` + +Recommended initial parameters: + +- `query: Option` + +Recommended initial output: + +- structured output containing recalled context and whether anything was found + +Example shape: + +```json +{ + "recalled": true, + "context": "..." +} +``` + +If nothing is found: + +```json +{ + "recalled": false, + "context": "" +} +``` + +Design rule: + +- expose recall to the assistant first +- do not expose destructive memory-drop behavior to the assistant in this lane +- do not expose memory-update to the assistant unless a concrete product need + emerges later + +Rationale: + +- recall helps the assistant reason +- update is operational and low-value per turn +- drop is destructive and should remain explicit human control unless policy + changes later + +## Enablement Gates + +The assistant-facing recall tool should be exposed only when both are true: + +- `Feature::MemoryTool` is enabled +- `config.memories.backend == Agentmemory` + +Do not add a new feature flag unless rollout isolation is necessary. + +Do not gate the assistant-facing recall tool on `memories.use_memories`. + +Rationale: + +- current `agentmemory` startup behavior already special-cases + `backend == Agentmemory` +- using `use_memories` here would create inconsistent behavior between startup + retrieval and mid-session retrieval + +## Tool/Slash Semantics + +The slash command and assistant tool should share the same backend semantics: + +- same backend +- same query behavior +- same token-budget policy +- same session scoping + +They should differ only in presentation: + +- slash command: + - inject recalled context into the active conversation + - show user-visible history output +- assistant tool: + - return recalled context to the assistant as tool output + - let the assistant decide how to use it in the current turn + +This means the human and assistant surfaces are parallel views over one memory +engine, not separate systems. + +## TUI Requirements + +The TUI must treat memory commands as visible product actions, not invisible +internal mutations. + +Required behavior: + +- immediate submit acknowledgment in history +- visible completion/result message in history +- no success path that only changes assistant context silently + +This applies to both: + +- `tui` +- `tui_app_server` + +The two implementations must stay behaviorally aligned. + +## Documentation Requirements + +Once the assistant-facing tool exists, documentation and runtime instructions +must stop implying a tool exists when none is callable. + +Required follow-up: + +- align developer/runtime instructions with the actual callable tool surface +- avoid telling the assistant to use "AgentMemory tools" unless such tools are + actually present in the current runtime + +## Non-Goals + +This lane does not include: + +- MCP exposure +- a second memory backend +- hidden auto-recall on arbitrary turns +- assistant-facing memory-drop +- assistant-facing memory-update by default +- reintroducing static `MEMORY.md`-style loading on top of `agentmemory` + +## Rollout Order + +1. Stabilize human-visible slash-command behavior for recall/update/drop. +2. Factor or confirm one shared core recall path backed by `AgentmemoryAdapter`. +3. Add the assistant-facing `memory_recall` internal tool. +4. Align runtime instructions and tool-surface documentation with reality. +5. Add focused tests for: + - human submit feedback + - human success/empty/error rendering + - assistant tool exposure gates + - assistant tool recall output + +## Acceptance Criteria + +The lane is done when all of the following are true: + +- a human pressing Enter on `/memory-recall` sees immediate feedback in the TUI +- a human pressing Enter on successful `/memory-recall` sees recalled context in + the TUI history +- `/memory-update` and `/memory-drop` visibly acknowledge both submission and + completion +- the assistant can call a first-class internal recall tool when memory is + enabled for `agentmemory` +- the assistant no longer has to infer memory availability from unrelated MCP + surfaces +- there is one canonical core recall path, not parallel retrieval + implementations + +## File Plan + +Expected primary files for this lane: + +- `core/src/agentmemory/mod.rs` +- `core/src/codex.rs` +- `core/src/tools/spec.rs` +- `core/src/tools/handlers/mod.rs` +- `core/src/tools/handlers/`: + add a dedicated memory-recall handler module +- `core/src/tools/spec_tests.rs` +- `tui/src/chatwidget.rs` +- `tui/src/chatwidget/tests.rs` +- `tui_app_server/src/chatwidget.rs` +- `tui_app_server/src/chatwidget/tests.rs` + +## Practical Recommendation + +Do not rush into auto-recall heuristics or extra protocols. + +Build the lane in this order: + +- make the human-visible slash-command path truthful and obvious +- expose one assistant-facing recall tool on top of the same core path +- only then evaluate whether proactive or automatic recall behavior is worth + adding + +That preserves a coherent product model: + +- one memory engine +- one core implementation +- two explicit runtime surfaces +- zero MCP dependency for this lane diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index d6188dec9..176923cb9 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -2217,6 +2217,19 @@ impl ChatWidget { self.request_redraw(); } + fn ensure_memory_recall_thread(&mut self) -> bool { + if self.thread_id.is_some() { + return true; + } + + self.add_error_message( + "Start a new chat or resume an existing thread before using /memory-recall." + .to_string(), + ); + self.bottom_pane.drain_pending_submission_state(); + false + } + fn on_mcp_startup_update(&mut self, ev: McpStartupUpdateEvent) { let mut status = self.mcp_startup_status.take().unwrap_or_default(); if let McpStartupStatus::Failed { error } = &ev.status { @@ -4798,12 +4811,33 @@ impl ChatWidget { self.clean_background_terminals(); } SlashCommand::MemoryDrop => { + self.add_info_message( + "Dropping stored memories...".to_string(), + Some("Codex will report whether the memory store was cleared.".to_string()), + ); self.submit_op(Op::DropMemories); } SlashCommand::MemoryUpdate => { + self.add_info_message( + "Triggering memory update...".to_string(), + Some( + "Codex will report when the memory refresh request has been accepted." + .to_string(), + ), + ); self.submit_op(Op::UpdateMemories); } SlashCommand::MemoryRecall => { + if !self.ensure_memory_recall_thread() { + return; + } + self.add_info_message( + "Recalling memory context...".to_string(), + Some( + "Recalled memory will be injected into the current thread and shown here." + .to_string(), + ), + ); self.submit_op(Op::RecallMemories { query: None }); } SlashCommand::Mcp => { @@ -4998,6 +5032,16 @@ impl ChatWidget { self.bottom_pane.drain_pending_submission_state(); } SlashCommand::MemoryRecall if !trimmed.is_empty() => { + if !self.ensure_memory_recall_thread() { + return; + } + self.add_info_message( + format!("Recalling memory context for: {trimmed}"), + Some( + "Recalled memory will be injected into the current thread and shown here." + .to_string(), + ), + ); self.submit_op(Op::RecallMemories { query: Some(trimmed.to_string()), }); diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index cba1b285e..441d5907f 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -6513,22 +6513,149 @@ async fn slash_clear_is_disabled_while_task_running() { #[tokio::test] async fn slash_memory_drop_submits_drop_memories_op() { - let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await; + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; chat.dispatch_command(SlashCommand::MemoryDrop); + let event = rx.try_recv().expect("expected memory drop info"); + match event { + AppEvent::InsertHistoryCell(cell) => { + let rendered = lines_to_single_string(&cell.display_lines(80)); + assert!( + rendered.contains("Dropping stored memories..."), + "expected memory drop info, got {rendered:?}" + ); + } + other => panic!("expected InsertHistoryCell info, got {other:?}"), + } assert_matches!(op_rx.try_recv(), Ok(Op::DropMemories)); } #[tokio::test] async fn slash_memory_update_submits_update_memories_op() { - let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await; + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; chat.dispatch_command(SlashCommand::MemoryUpdate); + let event = rx.try_recv().expect("expected memory update info"); + match event { + AppEvent::InsertHistoryCell(cell) => { + let rendered = lines_to_single_string(&cell.display_lines(80)); + assert!( + rendered.contains("Triggering memory update..."), + "expected memory update info, got {rendered:?}" + ); + } + other => panic!("expected InsertHistoryCell info, got {other:?}"), + } assert_matches!(op_rx.try_recv(), Ok(Op::UpdateMemories)); } +#[tokio::test] +async fn slash_memory_recall_requires_existing_thread() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; + chat.bottom_pane.set_agentmemory_enabled(true); + + chat.dispatch_command(SlashCommand::MemoryRecall); + + let event = rx.try_recv().expect("expected missing-thread error"); + match event { + AppEvent::InsertHistoryCell(cell) => { + let rendered = lines_to_single_string(&cell.display_lines(80)); + assert!( + rendered.contains( + "Start a new chat or resume an existing thread before using /memory-recall." + ), + "expected /memory-recall thread requirement error, got {rendered:?}" + ); + } + other => panic!("expected InsertHistoryCell error, got {other:?}"), + } + assert!(op_rx.try_recv().is_err(), "expected no core op to be sent"); +} + +#[tokio::test] +async fn slash_memory_recall_with_inline_args_requires_existing_thread() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; + chat.bottom_pane.set_agentmemory_enabled(true); + + chat.bottom_pane.set_composer_text( + "/memory-recall retrieval freshness".to_string(), + Vec::new(), + Vec::new(), + ); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + let event = rx.try_recv().expect("expected missing-thread error"); + match event { + AppEvent::InsertHistoryCell(cell) => { + let rendered = lines_to_single_string(&cell.display_lines(80)); + assert!( + rendered.contains( + "Start a new chat or resume an existing thread before using /memory-recall." + ), + "expected /memory-recall thread requirement error, got {rendered:?}" + ); + } + other => panic!("expected InsertHistoryCell error, got {other:?}"), + } + assert!(op_rx.try_recv().is_err(), "expected no core op to be sent"); +} + +#[tokio::test] +async fn slash_memory_recall_submits_recall_memories_op() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; + chat.bottom_pane.set_agentmemory_enabled(true); + chat.thread_id = Some(ThreadId::new()); + + chat.dispatch_command(SlashCommand::MemoryRecall); + + let event = rx.try_recv().expect("expected memory recall info"); + match event { + AppEvent::InsertHistoryCell(cell) => { + let rendered = lines_to_single_string(&cell.display_lines(80)); + assert!( + rendered.contains("Recalling memory context..."), + "expected memory recall info, got {rendered:?}" + ); + } + other => panic!("expected InsertHistoryCell info, got {other:?}"), + } + assert_matches!(op_rx.try_recv(), Ok(Op::RecallMemories { query: None })); +} + +#[tokio::test] +async fn slash_memory_recall_with_inline_args_submits_query() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; + chat.bottom_pane.set_agentmemory_enabled(true); + chat.thread_id = Some(ThreadId::new()); + + chat.bottom_pane.set_composer_text( + "/memory-recall retrieval freshness".to_string(), + Vec::new(), + Vec::new(), + ); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + let event = rx.try_recv().expect("expected memory recall info"); + match event { + AppEvent::InsertHistoryCell(cell) => { + let rendered = lines_to_single_string(&cell.display_lines(80)); + assert!( + rendered.contains("Recalling memory context for: retrieval freshness"), + "expected memory recall info, got {rendered:?}" + ); + } + other => panic!("expected InsertHistoryCell info, got {other:?}"), + } + assert_matches!( + op_rx.try_recv(), + Ok(Op::RecallMemories { + query: Some(query) + }) if query == "retrieval freshness" + ); +} + #[tokio::test] async fn slash_resume_opens_picker() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; diff --git a/codex-rs/tui_app_server/src/chatwidget.rs b/codex-rs/tui_app_server/src/chatwidget.rs index 39c880dce..aca79bfb1 100644 --- a/codex-rs/tui_app_server/src/chatwidget.rs +++ b/codex-rs/tui_app_server/src/chatwidget.rs @@ -5052,13 +5052,34 @@ impl ChatWidget { self.clean_background_terminals(); } SlashCommand::MemoryDrop => { + self.add_info_message( + "Dropping stored memories...".to_string(), + Some("Codex will report whether the memory store was cleared.".to_string()), + ); self.submit_op(AppCommand::memory_drop()); } SlashCommand::MemoryUpdate => { + self.add_info_message( + "Triggering memory update...".to_string(), + Some( + "Codex will report when the memory refresh request has been accepted." + .to_string(), + ), + ); self.submit_op(AppCommand::memory_update()); } SlashCommand::MemoryRecall => { - self.submit_op(AppCommand::memory_recall(None)); + if !self.ensure_memory_recall_thread() { + return; + } + self.add_info_message( + "Recalling memory context...".to_string(), + Some( + "Recalled memory will be injected into the current thread and shown here." + .to_string(), + ), + ); + self.submit_op(AppCommand::memory_recall(/*query*/ None)); } SlashCommand::Mcp => { self.add_mcp_output(); @@ -5229,12 +5250,22 @@ impl ChatWidget { self.bottom_pane.drain_pending_submission_state(); } SlashCommand::MemoryRecall if !trimmed.is_empty() => { + if !self.ensure_memory_recall_thread() { + return; + } let Some((prepared_args, _prepared_elements)) = self .bottom_pane .prepare_inline_args_submission(/*record_history*/ false) else { return; }; + self.add_info_message( + format!("Recalling memory context for: {trimmed}"), + Some( + "Recalled memory will be injected into the current thread and shown here." + .to_string(), + ), + ); self.submit_op(AppCommand::memory_recall(Some(prepared_args))); self.bottom_pane.drain_pending_submission_state(); } @@ -9806,6 +9837,19 @@ impl ChatWidget { self.request_redraw(); } + fn ensure_memory_recall_thread(&mut self) -> bool { + if self.thread_id.is_some() { + return true; + } + + self.add_error_message( + "Start a new chat or resume an existing thread before using /memory-recall." + .to_string(), + ); + self.bottom_pane.drain_pending_submission_state(); + false + } + fn rename_confirmation_cell(name: &str, thread_id: Option) -> PlainHistoryCell { let resume_cmd = codex_core::util::resume_command(Some(name), thread_id) .unwrap_or_else(|| format!("codex resume {name}")); diff --git a/codex-rs/tui_app_server/src/chatwidget/tests.rs b/codex-rs/tui_app_server/src/chatwidget/tests.rs index 40f5ad970..88ed39007 100644 --- a/codex-rs/tui_app_server/src/chatwidget/tests.rs +++ b/codex-rs/tui_app_server/src/chatwidget/tests.rs @@ -7289,6 +7289,17 @@ async fn slash_memory_drop_submits_core_op() { chat.dispatch_command(SlashCommand::MemoryDrop); + let event = rx.try_recv().expect("expected memory drop info"); + match event { + AppEvent::InsertHistoryCell(cell) => { + let rendered = lines_to_single_string(&cell.display_lines(80)); + assert!( + rendered.contains("Dropping stored memories..."), + "expected memory drop info, got {rendered:?}" + ); + } + other => panic!("expected InsertHistoryCell info, got {other:?}"), + } assert_matches!(op_rx.try_recv(), Ok(Op::DropMemories)); assert!(rx.try_recv().is_err(), "expected no stub message"); } @@ -7311,25 +7322,100 @@ async fn slash_memory_update_submits_core_op() { chat.dispatch_command(SlashCommand::MemoryUpdate); + let event = rx.try_recv().expect("expected memory update info"); + match event { + AppEvent::InsertHistoryCell(cell) => { + let rendered = lines_to_single_string(&cell.display_lines(80)); + assert!( + rendered.contains("Triggering memory update..."), + "expected memory update info, got {rendered:?}" + ); + } + other => panic!("expected InsertHistoryCell info, got {other:?}"), + } assert_matches!(op_rx.try_recv(), Ok(Op::UpdateMemories)); assert!(rx.try_recv().is_err(), "expected no stub message"); } +#[tokio::test] +async fn slash_memory_recall_requires_existing_thread() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; + chat.bottom_pane.set_agentmemory_enabled(true); + + chat.dispatch_command(SlashCommand::MemoryRecall); + + let event = rx.try_recv().expect("expected missing-thread error"); + match event { + AppEvent::InsertHistoryCell(cell) => { + let rendered = lines_to_single_string(&cell.display_lines(80)); + assert!( + rendered.contains( + "Start a new chat or resume an existing thread before using /memory-recall." + ), + "expected /memory-recall thread requirement error, got {rendered:?}" + ); + } + other => panic!("expected InsertHistoryCell error, got {other:?}"), + } + assert!(op_rx.try_recv().is_err(), "expected no core op to be sent"); +} + #[tokio::test] async fn slash_memory_recall_submits_core_op() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; chat.bottom_pane.set_agentmemory_enabled(true); + chat.thread_id = Some(ThreadId::new()); chat.dispatch_command(SlashCommand::MemoryRecall); + let event = rx.try_recv().expect("expected memory recall info"); + match event { + AppEvent::InsertHistoryCell(cell) => { + let rendered = lines_to_single_string(&cell.display_lines(80)); + assert!( + rendered.contains("Recalling memory context..."), + "expected memory recall info, got {rendered:?}" + ); + } + other => panic!("expected InsertHistoryCell info, got {other:?}"), + } assert_matches!(op_rx.try_recv(), Ok(Op::RecallMemories { query: None })); assert!(rx.try_recv().is_err(), "expected no stub message"); } +#[tokio::test] +async fn slash_memory_recall_with_inline_args_requires_existing_thread() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; + chat.bottom_pane.set_agentmemory_enabled(true); + + chat.bottom_pane.set_composer_text( + "/memory-recall retrieval freshness".to_string(), + Vec::new(), + Vec::new(), + ); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + let event = rx.try_recv().expect("expected missing-thread error"); + match event { + AppEvent::InsertHistoryCell(cell) => { + let rendered = lines_to_single_string(&cell.display_lines(80)); + assert!( + rendered.contains( + "Start a new chat or resume an existing thread before using /memory-recall." + ), + "expected /memory-recall thread requirement error, got {rendered:?}" + ); + } + other => panic!("expected InsertHistoryCell error, got {other:?}"), + } + assert!(op_rx.try_recv().is_err(), "expected no core op to be sent"); +} + #[tokio::test] async fn slash_memory_recall_with_inline_args_submits_query() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; chat.bottom_pane.set_agentmemory_enabled(true); + chat.thread_id = Some(ThreadId::new()); chat.bottom_pane.set_composer_text( "/memory-recall retrieval freshness".to_string(), @@ -7338,6 +7424,17 @@ async fn slash_memory_recall_with_inline_args_submits_query() { ); chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + let event = rx.try_recv().expect("expected memory recall info"); + match event { + AppEvent::InsertHistoryCell(cell) => { + let rendered = lines_to_single_string(&cell.display_lines(80)); + assert!( + rendered.contains("Recalling memory context for: retrieval freshness"), + "expected memory recall info, got {rendered:?}" + ); + } + other => panic!("expected InsertHistoryCell info, got {other:?}"), + } assert_matches!( op_rx.try_recv(), Ok(Op::RecallMemories { From 09311f040fadbcc3ce06ace24bd7f642e5e4427d Mon Sep 17 00:00:00 2001 From: Eric Juta Date: Sun, 29 Mar 2026 23:49:13 +0100 Subject: [PATCH 34/45] Refine proactive agentmemory recall guidance - teach the runtime to use memory_recall more selectively and proactively - clarify targeted query strategy in startup instructions and tool description Co-authored-by: Codex --- codex-rs/core/src/agentmemory/mod.rs | 11 ++++++++--- codex-rs/core/src/tools/spec.rs | 2 +- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/codex-rs/core/src/agentmemory/mod.rs b/codex-rs/core/src/agentmemory/mod.rs index fd5971dc5..d8b9d8a3a 100644 --- a/codex-rs/core/src/agentmemory/mod.rs +++ b/codex-rs/core/src/agentmemory/mod.rs @@ -74,9 +74,10 @@ impl AgentmemoryAdapter { let context_result = client.post(&url).json(&request_body).send().await; let mut instructions = - "Use the `memory_recall` tool to retrieve relevant historical context when needed.\n\ + "Use the `memory_recall` tool when the user asks about prior work, earlier decisions, previous failures, resumed threads, or other historical context that is not fully present in the current thread.\n\ Agentmemory startup context may be attached below when available.\n\ - Your context is bounded; prefer targeted recall queries over broad memory fetches." + Prefer targeted recall queries naming the feature, file, bug, or decision you need.\n\ + Do not call `memory_recall` on every turn; first use the current thread context, then recall memory when that context appears insufficient." .to_string(); if let Ok(res) = context_result @@ -554,8 +555,12 @@ mod tests { .expect("instructions should be returned"); assert!(instructions.contains("Use the `memory_recall` tool")); + assert!(instructions.contains("prior work, earlier decisions, previous failures")); assert!(instructions.contains("Agentmemory startup context may be attached below")); - assert!(instructions.contains("prefer targeted recall queries over broad memory fetches")); + assert!(instructions.contains( + "Prefer targeted recall queries naming the feature, file, bug, or decision you need" + )); + assert!(instructions.contains("Do not call `memory_recall` on every turn")); } #[tokio::test] diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index 840aa5b54..3584e1394 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -1781,7 +1781,7 @@ fn create_memory_recall_tool() -> ToolSpec { ToolSpec::Function(ResponsesApiTool { name: "memory_recall".to_string(), - description: "Recall relevant agentmemory context for the current thread and project. Use this when historical context is needed and live memory is available in the current runtime." + description: "Recall relevant agentmemory context for the current thread and project. Use this when prior work, design rationale, earlier failures, or cross-session continuity matter and the current thread does not already contain enough context. Prefer targeted queries naming the feature, file, bug, or decision you need." .to_string(), strict: false, defer_loading: None, From 7a9e3b1a23330d9bb1d6764df1b0f62f9e6a387c Mon Sep 17 00:00:00 2001 From: Eric Juta Date: Mon, 30 Mar 2026 00:43:45 +0100 Subject: [PATCH 35/45] Add visual memory history cells - render dedicated memory cells for recall, update, and drop in both TUIs - route memory warnings/errors through visual memory UI instead of generic bullets - update runtime and payload specs for the human-facing memory UI Co-authored-by: Codex --- .../docs/agentmemory_payload_quality_spec.md | 2 + .../docs/agentmemory_runtime_surface_spec.md | 31 ++ codex-rs/tui/src/chatwidget.rs | 56 ++-- codex-rs/tui/src/chatwidget/tests.rs | 8 +- codex-rs/tui/src/history_cell.rs | 271 ++++++++++++++++++ codex-rs/tui_app_server/src/chatwidget.rs | 56 ++-- .../tui_app_server/src/chatwidget/tests.rs | 8 +- codex-rs/tui_app_server/src/history_cell.rs | 271 ++++++++++++++++++ 8 files changed, 633 insertions(+), 70 deletions(-) diff --git a/codex-rs/docs/agentmemory_payload_quality_spec.md b/codex-rs/docs/agentmemory_payload_quality_spec.md index 944ddeb29..67aea35ea 100644 --- a/codex-rs/docs/agentmemory_payload_quality_spec.md +++ b/codex-rs/docs/agentmemory_payload_quality_spec.md @@ -60,6 +60,8 @@ markers. - what conclusion or decision mattered 3. File-sensitive tasks should yield observations and memories that mention the relevant paths and search terms when available. +4. Manual memory recall should be inspectable by the human in the TUI, not only + injected into assistant context. ## Proposed Changes diff --git a/codex-rs/docs/agentmemory_runtime_surface_spec.md b/codex-rs/docs/agentmemory_runtime_surface_spec.md index deb089355..ff7d4dc6e 100644 --- a/codex-rs/docs/agentmemory_runtime_surface_spec.md +++ b/codex-rs/docs/agentmemory_runtime_surface_spec.md @@ -121,6 +121,32 @@ Required UX behavior: - on recall without a thread: - show an explicit thread/session requirement message +### Visual Memory UI + +The human-facing memory actions should render as dedicated memory UI cells, not +generic warning/info text. + +Minimum visual fields: + +- operation: + - recall + - update + - drop +- status: + - pending + - ready + - empty + - error +- query when present +- whether recalled context was injected into the current thread +- a wrapped preview body for recalled context or error detail + +Design rule: + +- do not make the human infer memory activity from generic bullets or warning + styling alone +- memory actions should be visually recognizable at a glance in the transcript + Human-surface principle: - memory actions must be observable by the human, not only by the assistant. @@ -221,6 +247,7 @@ Required behavior: - immediate submit acknowledgment in history - visible completion/result message in history +- dedicated visual memory cells for submit and completion states - no success path that only changes assistant context silently This applies to both: @@ -271,6 +298,8 @@ The lane is done when all of the following are true: - a human pressing Enter on `/memory-recall` sees immediate feedback in the TUI - a human pressing Enter on successful `/memory-recall` sees recalled context in the TUI history +- the recall/update/drop history entries are visually distinct memory cells, not + generic warnings or info bullets - `/memory-update` and `/memory-drop` visibly acknowledge both submission and completion - the assistant can call a first-class internal recall tool when memory is @@ -292,8 +321,10 @@ Expected primary files for this lane: add a dedicated memory-recall handler module - `core/src/tools/spec_tests.rs` - `tui/src/chatwidget.rs` +- `tui/src/history_cell.rs` - `tui/src/chatwidget/tests.rs` - `tui_app_server/src/chatwidget.rs` +- `tui_app_server/src/history_cell.rs` - `tui_app_server/src/chatwidget/tests.rs` ## Practical Recommendation diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 176923cb9..5feecd6cd 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -2205,7 +2205,11 @@ impl ChatWidget { fn on_error(&mut self, message: String) { self.submit_pending_steers_after_interrupt = false; self.finalize_turn(); - self.add_to_history(history_cell::new_error_event(message)); + if let Some(cell) = history_cell::memory_error_event(&message) { + self.add_to_history(cell); + } else { + self.add_to_history(history_cell::new_error_event(message)); + } self.request_redraw(); // After an error ends the turn, try sending the next queued input. @@ -2213,7 +2217,12 @@ impl ChatWidget { } fn on_warning(&mut self, message: impl Into) { - self.add_to_history(history_cell::new_warning_event(message.into())); + let message = message.into(); + if let Some(cell) = history_cell::memory_warning_event(&message) { + self.add_to_history(cell); + } else { + self.add_to_history(history_cell::new_warning_event(message)); + } self.request_redraw(); } @@ -2222,10 +2231,8 @@ impl ChatWidget { return true; } - self.add_error_message( - "Start a new chat or resume an existing thread before using /memory-recall." - .to_string(), - ); + self.add_to_history(history_cell::new_memory_recall_thread_requirement()); + self.request_redraw(); self.bottom_pane.drain_pending_submission_state(); false } @@ -4811,33 +4818,23 @@ impl ChatWidget { self.clean_background_terminals(); } SlashCommand::MemoryDrop => { - self.add_info_message( - "Dropping stored memories...".to_string(), - Some("Codex will report whether the memory store was cleared.".to_string()), - ); + self.add_to_history(history_cell::new_memory_drop_submission()); + self.request_redraw(); self.submit_op(Op::DropMemories); } SlashCommand::MemoryUpdate => { - self.add_info_message( - "Triggering memory update...".to_string(), - Some( - "Codex will report when the memory refresh request has been accepted." - .to_string(), - ), - ); + self.add_to_history(history_cell::new_memory_update_submission()); + self.request_redraw(); self.submit_op(Op::UpdateMemories); } SlashCommand::MemoryRecall => { if !self.ensure_memory_recall_thread() { return; } - self.add_info_message( - "Recalling memory context...".to_string(), - Some( - "Recalled memory will be injected into the current thread and shown here." - .to_string(), - ), - ); + self.add_to_history(history_cell::new_memory_recall_submission( + /*query*/ None, + )); + self.request_redraw(); self.submit_op(Op::RecallMemories { query: None }); } SlashCommand::Mcp => { @@ -5035,13 +5032,10 @@ impl ChatWidget { if !self.ensure_memory_recall_thread() { return; } - self.add_info_message( - format!("Recalling memory context for: {trimmed}"), - Some( - "Recalled memory will be injected into the current thread and shown here." - .to_string(), - ), - ); + self.add_to_history(history_cell::new_memory_recall_submission(Some( + trimmed.to_string(), + ))); + self.request_redraw(); self.submit_op(Op::RecallMemories { query: Some(trimmed.to_string()), }); diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 441d5907f..ce27a872d 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -6522,7 +6522,7 @@ async fn slash_memory_drop_submits_drop_memories_op() { AppEvent::InsertHistoryCell(cell) => { let rendered = lines_to_single_string(&cell.display_lines(80)); assert!( - rendered.contains("Dropping stored memories..."), + rendered.contains("Memory Drop"), "expected memory drop info, got {rendered:?}" ); } @@ -6542,7 +6542,7 @@ async fn slash_memory_update_submits_update_memories_op() { AppEvent::InsertHistoryCell(cell) => { let rendered = lines_to_single_string(&cell.display_lines(80)); assert!( - rendered.contains("Triggering memory update..."), + rendered.contains("Memory Update"), "expected memory update info, got {rendered:?}" ); } @@ -6615,7 +6615,7 @@ async fn slash_memory_recall_submits_recall_memories_op() { AppEvent::InsertHistoryCell(cell) => { let rendered = lines_to_single_string(&cell.display_lines(80)); assert!( - rendered.contains("Recalling memory context..."), + rendered.contains("Memory Recall"), "expected memory recall info, got {rendered:?}" ); } @@ -6642,7 +6642,7 @@ async fn slash_memory_recall_with_inline_args_submits_query() { AppEvent::InsertHistoryCell(cell) => { let rendered = lines_to_single_string(&cell.display_lines(80)); assert!( - rendered.contains("Recalling memory context for: retrieval freshness"), + rendered.contains("Query: retrieval freshness"), "expected memory recall info, got {rendered:?}" ); } diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 2dee1e846..dc20f6c3a 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -1983,6 +1983,250 @@ pub(crate) fn new_error_event(message: String) -> PlainHistoryCell { PlainHistoryCell { lines } } +const MEMORY_PREVIEW_MAX_GRAPHEMES: usize = 1_200; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum MemoryOperationKind { + Recall, + Update, + Drop, +} + +impl MemoryOperationKind { + fn title(self) -> &'static str { + match self { + Self::Recall => "Memory Recall", + Self::Update => "Memory Update", + Self::Drop => "Memory Drop", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum MemoryOperationState { + Pending, + Success, + Empty, + Error, +} + +#[derive(Debug)] +pub(crate) struct MemoryHistoryCell { + operation: MemoryOperationKind, + state: MemoryOperationState, + query: Option, + summary: String, + detail: Option, +} + +impl MemoryHistoryCell { + fn new( + operation: MemoryOperationKind, + state: MemoryOperationState, + query: Option, + summary: String, + detail: Option, + ) -> Self { + Self { + operation, + state, + query, + summary, + detail: detail + .map(|detail| detail.trim().to_string()) + .filter(|detail| !detail.is_empty()), + } + } + + fn state_span(&self) -> Span<'static> { + match self.state { + MemoryOperationState::Pending => "Pending".cyan().bold(), + MemoryOperationState::Success => "Ready".green().bold(), + MemoryOperationState::Empty => "Empty".magenta().bold(), + MemoryOperationState::Error => "Error".red().bold(), + } + } + + fn preview_detail(&self) -> Option { + self.detail + .as_deref() + .map(|detail| truncate_text(detail, MEMORY_PREVIEW_MAX_GRAPHEMES)) + } +} + +impl HistoryCell for MemoryHistoryCell { + fn display_lines(&self, width: u16) -> Vec> { + let wrap_width = width.max(1) as usize; + let mut lines = vec![ + vec![ + "🧠 ".into(), + self.operation.title().bold(), + " ".into(), + self.state_span(), + ] + .into(), + ]; + + if let Some(query) = &self.query { + let query_line = Line::from(vec![" Query: ".dim(), query.clone().into()]); + let wrapped = adaptive_wrap_line(&query_line, RtOptions::new(wrap_width)); + push_owned_lines(&wrapped, &mut lines); + } + + let summary_line = Line::from(vec![" ".into(), self.summary.clone().into()]); + let wrapped_summary = adaptive_wrap_line(&summary_line, RtOptions::new(wrap_width)); + push_owned_lines(&wrapped_summary, &mut lines); + + if let Some(detail) = self.preview_detail() { + lines.push(Line::from(vec![" Preview:".dim()])); + let detail_line = Line::from(detail); + let wrapped_detail = adaptive_wrap_line( + &detail_line, + RtOptions::new(wrap_width) + .initial_indent(Line::from(" ")) + .subsequent_indent(Line::from(" ")), + ); + push_owned_lines(&wrapped_detail, &mut lines); + } + + lines + } +} + +fn parse_memory_query(message: &str) -> Option { + let marker = " for query: "; + let start = message.find(marker)? + marker.len(); + let tail = &message[start..]; + let end = tail + .find(" and ") + .or_else(|| tail.find('.')) + .unwrap_or(tail.len()); + let query = tail[..end].trim(); + (!query.is_empty()).then(|| query.to_string()) +} + +pub(crate) fn new_memory_recall_submission(query: Option) -> MemoryHistoryCell { + MemoryHistoryCell::new( + MemoryOperationKind::Recall, + MemoryOperationState::Pending, + query, + "Recalling memory context for the current thread.".to_string(), + /*detail*/ None, + ) +} + +pub(crate) fn new_memory_update_submission() -> MemoryHistoryCell { + MemoryHistoryCell::new( + MemoryOperationKind::Update, + MemoryOperationState::Pending, + /*query*/ None, + "Requesting a memory refresh.".to_string(), + /*detail*/ None, + ) +} + +pub(crate) fn new_memory_drop_submission() -> MemoryHistoryCell { + MemoryHistoryCell::new( + MemoryOperationKind::Drop, + MemoryOperationState::Pending, + /*query*/ None, + "Dropping stored memories for this workspace.".to_string(), + /*detail*/ None, + ) +} + +pub(crate) fn new_memory_recall_thread_requirement() -> MemoryHistoryCell { + MemoryHistoryCell::new( + MemoryOperationKind::Recall, + MemoryOperationState::Error, + /*query*/ None, + "Start a new chat or resume an existing thread before using /memory-recall.".to_string(), + /*detail*/ None, + ) +} + +pub(crate) fn memory_warning_event(message: &str) -> Option { + if let Some((summary, detail)) = message.split_once("\n\n") + && summary.starts_with("Memory context recalled") + { + return Some(MemoryHistoryCell::new( + MemoryOperationKind::Recall, + MemoryOperationState::Success, + parse_memory_query(summary), + "Recalled memory context and injected it into the current thread.".to_string(), + Some(detail.to_string()), + )); + } + + if message.starts_with("No relevant memory context found") { + return Some(MemoryHistoryCell::new( + MemoryOperationKind::Recall, + MemoryOperationState::Empty, + parse_memory_query(message), + "No relevant memory context was found.".to_string(), + /*detail*/ None, + )); + } + + if message.starts_with("Agentmemory sync triggered") + || message.starts_with("Memory update triggered") + { + return Some(MemoryHistoryCell::new( + MemoryOperationKind::Update, + MemoryOperationState::Success, + /*query*/ None, + message.to_string(), + /*detail*/ None, + )); + } + + if message.starts_with("Memory drop completed") { + return Some(MemoryHistoryCell::new( + MemoryOperationKind::Drop, + MemoryOperationState::Success, + /*query*/ None, + message.to_string(), + /*detail*/ None, + )); + } + + None +} + +pub(crate) fn memory_error_event(message: &str) -> Option { + if let Some(detail) = message.strip_prefix("Memory recall failed: ") { + return Some(MemoryHistoryCell::new( + MemoryOperationKind::Recall, + MemoryOperationState::Error, + /*query*/ None, + "Memory recall failed.".to_string(), + Some(detail.to_string()), + )); + } + + if let Some(detail) = message.strip_prefix("Agentmemory sync failed: ") { + return Some(MemoryHistoryCell::new( + MemoryOperationKind::Update, + MemoryOperationState::Error, + /*query*/ None, + "Memory update failed.".to_string(), + Some(detail.to_string()), + )); + } + + if let Some(detail) = message.strip_prefix("Memory drop completed with errors: ") { + return Some(MemoryHistoryCell::new( + MemoryOperationKind::Drop, + MemoryOperationState::Error, + /*query*/ None, + "Memory drop completed with errors.".to_string(), + Some(detail.to_string()), + )); + } + + None +} + /// Renders a completed (or interrupted) request_user_input exchange in history. #[derive(Debug)] pub(crate) struct RequestUserInputResultCell { @@ -2892,6 +3136,33 @@ mod tests { insta::assert_snapshot!(rendered); } + #[test] + fn memory_recall_submission_snapshot() { + let cell = new_memory_recall_submission(Some("retrieval freshness".to_string())); + let rendered = render_lines(&cell.display_lines(80)).join("\n"); + insta::assert_snapshot!(rendered, @r###" +🧠 Memory Recall Pending + Query: retrieval freshness + Recalling memory context for the current thread. +"###); + } + + #[test] + fn memory_recall_result_snapshot() { + let cell = memory_warning_event( + "Memory context recalled for query: retrieval freshness and injected into this thread:\n\nremember this", + ) + .expect("expected memory warning cell"); + let rendered = render_lines(&cell.display_lines(80)).join("\n"); + insta::assert_snapshot!(rendered, @r###" +🧠 Memory Recall Ready + Query: retrieval freshness + Recalled memory context and injected it into the current thread. + Preview: + remember this +"###); + } + #[tokio::test] async fn mcp_tools_output_masks_sensitive_values() { let mut config = test_config().await; diff --git a/codex-rs/tui_app_server/src/chatwidget.rs b/codex-rs/tui_app_server/src/chatwidget.rs index aca79bfb1..15d6add0e 100644 --- a/codex-rs/tui_app_server/src/chatwidget.rs +++ b/codex-rs/tui_app_server/src/chatwidget.rs @@ -2656,7 +2656,11 @@ impl ChatWidget { fn on_error(&mut self, message: String) { self.submit_pending_steers_after_interrupt = false; self.finalize_turn(); - self.add_to_history(history_cell::new_error_event(message)); + if let Some(cell) = history_cell::memory_error_event(&message) { + self.add_to_history(cell); + } else { + self.add_to_history(history_cell::new_error_event(message)); + } self.request_redraw(); // After an error ends the turn, try sending the next queued input. @@ -2688,7 +2692,12 @@ impl ChatWidget { } fn on_warning(&mut self, message: impl Into) { - self.add_to_history(history_cell::new_warning_event(message.into())); + let message = message.into(); + if let Some(cell) = history_cell::memory_warning_event(&message) { + self.add_to_history(cell); + } else { + self.add_to_history(history_cell::new_warning_event(message)); + } self.request_redraw(); } @@ -5052,33 +5061,23 @@ impl ChatWidget { self.clean_background_terminals(); } SlashCommand::MemoryDrop => { - self.add_info_message( - "Dropping stored memories...".to_string(), - Some("Codex will report whether the memory store was cleared.".to_string()), - ); + self.add_to_history(history_cell::new_memory_drop_submission()); + self.request_redraw(); self.submit_op(AppCommand::memory_drop()); } SlashCommand::MemoryUpdate => { - self.add_info_message( - "Triggering memory update...".to_string(), - Some( - "Codex will report when the memory refresh request has been accepted." - .to_string(), - ), - ); + self.add_to_history(history_cell::new_memory_update_submission()); + self.request_redraw(); self.submit_op(AppCommand::memory_update()); } SlashCommand::MemoryRecall => { if !self.ensure_memory_recall_thread() { return; } - self.add_info_message( - "Recalling memory context...".to_string(), - Some( - "Recalled memory will be injected into the current thread and shown here." - .to_string(), - ), - ); + self.add_to_history(history_cell::new_memory_recall_submission( + /*query*/ None, + )); + self.request_redraw(); self.submit_op(AppCommand::memory_recall(/*query*/ None)); } SlashCommand::Mcp => { @@ -5259,13 +5258,10 @@ impl ChatWidget { else { return; }; - self.add_info_message( - format!("Recalling memory context for: {trimmed}"), - Some( - "Recalled memory will be injected into the current thread and shown here." - .to_string(), - ), - ); + self.add_to_history(history_cell::new_memory_recall_submission(Some( + trimmed.to_string(), + ))); + self.request_redraw(); self.submit_op(AppCommand::memory_recall(Some(prepared_args))); self.bottom_pane.drain_pending_submission_state(); } @@ -9842,10 +9838,8 @@ impl ChatWidget { return true; } - self.add_error_message( - "Start a new chat or resume an existing thread before using /memory-recall." - .to_string(), - ); + self.add_to_history(history_cell::new_memory_recall_thread_requirement()); + self.request_redraw(); self.bottom_pane.drain_pending_submission_state(); false } diff --git a/codex-rs/tui_app_server/src/chatwidget/tests.rs b/codex-rs/tui_app_server/src/chatwidget/tests.rs index 88ed39007..9ad0172f0 100644 --- a/codex-rs/tui_app_server/src/chatwidget/tests.rs +++ b/codex-rs/tui_app_server/src/chatwidget/tests.rs @@ -7294,7 +7294,7 @@ async fn slash_memory_drop_submits_core_op() { AppEvent::InsertHistoryCell(cell) => { let rendered = lines_to_single_string(&cell.display_lines(80)); assert!( - rendered.contains("Dropping stored memories..."), + rendered.contains("Memory Drop"), "expected memory drop info, got {rendered:?}" ); } @@ -7327,7 +7327,7 @@ async fn slash_memory_update_submits_core_op() { AppEvent::InsertHistoryCell(cell) => { let rendered = lines_to_single_string(&cell.display_lines(80)); assert!( - rendered.contains("Triggering memory update..."), + rendered.contains("Memory Update"), "expected memory update info, got {rendered:?}" ); } @@ -7373,7 +7373,7 @@ async fn slash_memory_recall_submits_core_op() { AppEvent::InsertHistoryCell(cell) => { let rendered = lines_to_single_string(&cell.display_lines(80)); assert!( - rendered.contains("Recalling memory context..."), + rendered.contains("Memory Recall"), "expected memory recall info, got {rendered:?}" ); } @@ -7429,7 +7429,7 @@ async fn slash_memory_recall_with_inline_args_submits_query() { AppEvent::InsertHistoryCell(cell) => { let rendered = lines_to_single_string(&cell.display_lines(80)); assert!( - rendered.contains("Recalling memory context for: retrieval freshness"), + rendered.contains("Query: retrieval freshness"), "expected memory recall info, got {rendered:?}" ); } diff --git a/codex-rs/tui_app_server/src/history_cell.rs b/codex-rs/tui_app_server/src/history_cell.rs index 7e37a6bf9..325ddaf95 100644 --- a/codex-rs/tui_app_server/src/history_cell.rs +++ b/codex-rs/tui_app_server/src/history_cell.rs @@ -2163,6 +2163,250 @@ pub(crate) fn new_error_event(message: String) -> PlainHistoryCell { PlainHistoryCell { lines } } +const MEMORY_PREVIEW_MAX_GRAPHEMES: usize = 1_200; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum MemoryOperationKind { + Recall, + Update, + Drop, +} + +impl MemoryOperationKind { + fn title(self) -> &'static str { + match self { + Self::Recall => "Memory Recall", + Self::Update => "Memory Update", + Self::Drop => "Memory Drop", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum MemoryOperationState { + Pending, + Success, + Empty, + Error, +} + +#[derive(Debug)] +pub(crate) struct MemoryHistoryCell { + operation: MemoryOperationKind, + state: MemoryOperationState, + query: Option, + summary: String, + detail: Option, +} + +impl MemoryHistoryCell { + fn new( + operation: MemoryOperationKind, + state: MemoryOperationState, + query: Option, + summary: String, + detail: Option, + ) -> Self { + Self { + operation, + state, + query, + summary, + detail: detail + .map(|detail| detail.trim().to_string()) + .filter(|detail| !detail.is_empty()), + } + } + + fn state_span(&self) -> Span<'static> { + match self.state { + MemoryOperationState::Pending => "Pending".cyan().bold(), + MemoryOperationState::Success => "Ready".green().bold(), + MemoryOperationState::Empty => "Empty".magenta().bold(), + MemoryOperationState::Error => "Error".red().bold(), + } + } + + fn preview_detail(&self) -> Option { + self.detail + .as_deref() + .map(|detail| truncate_text(detail, MEMORY_PREVIEW_MAX_GRAPHEMES)) + } +} + +impl HistoryCell for MemoryHistoryCell { + fn display_lines(&self, width: u16) -> Vec> { + let wrap_width = width.max(1) as usize; + let mut lines = vec![ + vec![ + "🧠 ".into(), + self.operation.title().bold(), + " ".into(), + self.state_span(), + ] + .into(), + ]; + + if let Some(query) = &self.query { + let query_line = Line::from(vec![" Query: ".dim(), query.clone().into()]); + let wrapped = adaptive_wrap_line(&query_line, RtOptions::new(wrap_width)); + push_owned_lines(&wrapped, &mut lines); + } + + let summary_line = Line::from(vec![" ".into(), self.summary.clone().into()]); + let wrapped_summary = adaptive_wrap_line(&summary_line, RtOptions::new(wrap_width)); + push_owned_lines(&wrapped_summary, &mut lines); + + if let Some(detail) = self.preview_detail() { + lines.push(Line::from(vec![" Preview:".dim()])); + let detail_line = Line::from(detail); + let wrapped_detail = adaptive_wrap_line( + &detail_line, + RtOptions::new(wrap_width) + .initial_indent(Line::from(" ")) + .subsequent_indent(Line::from(" ")), + ); + push_owned_lines(&wrapped_detail, &mut lines); + } + + lines + } +} + +fn parse_memory_query(message: &str) -> Option { + let marker = " for query: "; + let start = message.find(marker)? + marker.len(); + let tail = &message[start..]; + let end = tail + .find(" and ") + .or_else(|| tail.find('.')) + .unwrap_or(tail.len()); + let query = tail[..end].trim(); + (!query.is_empty()).then(|| query.to_string()) +} + +pub(crate) fn new_memory_recall_submission(query: Option) -> MemoryHistoryCell { + MemoryHistoryCell::new( + MemoryOperationKind::Recall, + MemoryOperationState::Pending, + query, + "Recalling memory context for the current thread.".to_string(), + /*detail*/ None, + ) +} + +pub(crate) fn new_memory_update_submission() -> MemoryHistoryCell { + MemoryHistoryCell::new( + MemoryOperationKind::Update, + MemoryOperationState::Pending, + /*query*/ None, + "Requesting a memory refresh.".to_string(), + /*detail*/ None, + ) +} + +pub(crate) fn new_memory_drop_submission() -> MemoryHistoryCell { + MemoryHistoryCell::new( + MemoryOperationKind::Drop, + MemoryOperationState::Pending, + /*query*/ None, + "Dropping stored memories for this workspace.".to_string(), + /*detail*/ None, + ) +} + +pub(crate) fn new_memory_recall_thread_requirement() -> MemoryHistoryCell { + MemoryHistoryCell::new( + MemoryOperationKind::Recall, + MemoryOperationState::Error, + /*query*/ None, + "Start a new chat or resume an existing thread before using /memory-recall.".to_string(), + /*detail*/ None, + ) +} + +pub(crate) fn memory_warning_event(message: &str) -> Option { + if let Some((summary, detail)) = message.split_once("\n\n") + && summary.starts_with("Memory context recalled") + { + return Some(MemoryHistoryCell::new( + MemoryOperationKind::Recall, + MemoryOperationState::Success, + parse_memory_query(summary), + "Recalled memory context and injected it into the current thread.".to_string(), + Some(detail.to_string()), + )); + } + + if message.starts_with("No relevant memory context found") { + return Some(MemoryHistoryCell::new( + MemoryOperationKind::Recall, + MemoryOperationState::Empty, + parse_memory_query(message), + "No relevant memory context was found.".to_string(), + /*detail*/ None, + )); + } + + if message.starts_with("Agentmemory sync triggered") + || message.starts_with("Memory update triggered") + { + return Some(MemoryHistoryCell::new( + MemoryOperationKind::Update, + MemoryOperationState::Success, + /*query*/ None, + message.to_string(), + /*detail*/ None, + )); + } + + if message.starts_with("Memory drop completed") { + return Some(MemoryHistoryCell::new( + MemoryOperationKind::Drop, + MemoryOperationState::Success, + /*query*/ None, + message.to_string(), + /*detail*/ None, + )); + } + + None +} + +pub(crate) fn memory_error_event(message: &str) -> Option { + if let Some(detail) = message.strip_prefix("Memory recall failed: ") { + return Some(MemoryHistoryCell::new( + MemoryOperationKind::Recall, + MemoryOperationState::Error, + /*query*/ None, + "Memory recall failed.".to_string(), + Some(detail.to_string()), + )); + } + + if let Some(detail) = message.strip_prefix("Agentmemory sync failed: ") { + return Some(MemoryHistoryCell::new( + MemoryOperationKind::Update, + MemoryOperationState::Error, + /*query*/ None, + "Memory update failed.".to_string(), + Some(detail.to_string()), + )); + } + + if let Some(detail) = message.strip_prefix("Memory drop completed with errors: ") { + return Some(MemoryHistoryCell::new( + MemoryOperationKind::Drop, + MemoryOperationState::Error, + /*query*/ None, + "Memory drop completed with errors.".to_string(), + Some(detail.to_string()), + )); + } + + None +} + /// A transient history cell that shows an animated spinner while the MCP /// inventory RPC is in flight. /// @@ -3121,6 +3365,33 @@ mod tests { insta::assert_snapshot!(rendered); } + #[test] + fn memory_recall_submission_snapshot() { + let cell = new_memory_recall_submission(Some("retrieval freshness".to_string())); + let rendered = render_lines(&cell.display_lines(80)).join("\n"); + insta::assert_snapshot!(rendered, @r###" +🧠 Memory Recall Pending + Query: retrieval freshness + Recalling memory context for the current thread. +"###); + } + + #[test] + fn memory_recall_result_snapshot() { + let cell = memory_warning_event( + "Memory context recalled for query: retrieval freshness and injected into this thread:\n\nremember this", + ) + .expect("expected memory warning cell"); + let rendered = render_lines(&cell.display_lines(80)).join("\n"); + insta::assert_snapshot!(rendered, @r###" +🧠 Memory Recall Ready + Query: retrieval freshness + Recalled memory context and injected it into the current thread. + Preview: + remember this +"###); + } + #[tokio::test] async fn mcp_tools_output_masks_sensitive_values() { let mut config = test_config().await; From 2cf9895352f72956bcc40e9d37c6ca50d6fb23ae Mon Sep 17 00:00:00 2001 From: Eric Juta Date: Mon, 30 Mar 2026 01:18:04 +0100 Subject: [PATCH 36/45] Add agentmemory follow-up spec - capture the remaining agentmemory backlog and sequencing - freeze the next high-leverage lanes and non-goals Co-authored-by: Codex --- codex-rs/docs/agentmemory_followup_spec.md | 221 +++++++++++++++++++++ 1 file changed, 221 insertions(+) create mode 100644 codex-rs/docs/agentmemory_followup_spec.md diff --git a/codex-rs/docs/agentmemory_followup_spec.md b/codex-rs/docs/agentmemory_followup_spec.md new file mode 100644 index 000000000..2bb293445 --- /dev/null +++ b/codex-rs/docs/agentmemory_followup_spec.md @@ -0,0 +1,221 @@ +# Agentmemory Follow-Up Spec + +## Status + +Proposed follow-up backlog after the runtime-surface, proactive-guidance, and +visual-memory-UI lanes. + +This document exists to answer one practical question: + +- what is still worth doing after the current `agentmemory` integration work, +- in what order, +- and what should explicitly not be done yet. + +## Current State + +The fork already has: + +- `agentmemory` as the active long-term memory backend +- an assistant-facing `memory_recall` tool +- human-facing `/memory-recall`, `/memory-update`, and `/memory-drop` +- proactive runtime guidance for when the assistant should use recall +- dedicated visual memory history cells in both TUIs + +That means the system is functionally good. What remains is mostly structural +cleanup, richer human visibility, and better retrieval/capture quality. + +## Goal + +Turn the current good private-fork integration into the cleanest durable shape: + +- fewer duplicated UI heuristics +- clearer human visibility for all memory activity +- more structured memory events and metadata +- better long-term retrieval quality +- better end-to-end confidence + +## Priority Order + +### 1. Replace string-matched memory UI with structured memory events + +Current state: + +- both TUIs recognize memory outcomes by parsing core-emitted warning/error + strings + +Why this is next: + +- it removes duplicated parsing logic across `tui` and `tui_app_server` +- it makes the visual memory UI more robust against copy changes +- it creates the right hook point for surfacing assistant-triggered memory use + +Target shape: + +- a dedicated protocol event carrying: + - operation + - status + - query + - whether context was injected + - preview/detail payload + +Do not do: + +- broad protocol redesign beyond the memory event itself + +### 2. Replace append-only pending memory cards with in-place completion updates + +Current state: + +- the human sees a `Pending` card at submit time and then a second final card + +Why this is next: + +- the current UX is correct but noisy +- users can misread the persistent pending card as a stuck operation + +Target shape: + +- one memory card per operation +- pending transitions to ready/empty/error in place + +### 3. Surface assistant-triggered `memory_recall` to the human + +Current state: + +- human-triggered memory actions are visually shown +- assistant-triggered `memory_recall` is functionally real but not given the + same polished human-facing transcript treatment + +Why this matters: + +- users should be able to see when the assistant consulted long-term memory +- this improves trust and debuggability + +Target shape: + +- assistant-triggered memory recall produces the same visual memory event style + as human-triggered recall +- the UI should distinguish: + - tool returned context to the assistant + - context was injected into the current thread + +### 4. Add richer memory metadata to the human UI + +Current state: + +- memory cells show operation/query/status/preview +- they do not yet show richer recall metadata + +Useful additions: + +- block count +- token count +- backend/source label +- timestamp or relative freshness label + +Why this matters: + +- it helps users understand whether memory was broad, sparse, or stale + +### 5. Add a lightweight memory-availability indicator in the TUI + +Current state: + +- memory is visible when explicitly used +- there is no ambient signal that the current runtime has `agentmemory` recall + available + +Target shape: + +- a subtle status-line or bottom-pane indication when: + - backend is `agentmemory` + - `memory_recall` tool is available + +Do not do: + +- a large always-on panel + +### 6. Add end-to-end regression coverage for assistant memory use + +Current state: + +- focused tool/spec/TUI tests exist +- there is no single end-to-end regression proving the assistant actually calls + `memory_recall` in a realistic run and that the human can observe the right + result path + +Target additions: + +- a `codex exec`-style regression for assistant tool recall +- a TUI/app-server regression for visual memory event rendering + +### 7. Finish the payload-quality backlog + +This stays important even though the runtime surface is now solid. + +Still open from the payload-quality spec: + +- add tool-output size caps for `post_tool_use` +- selectively filter low-value `pre_tool_use` traffic +- create real-session quality evaluation fixtures + +Why this still matters: + +- retrieval quality will eventually matter more than UI polish + +### 8. Consider selective auto-recall only after the above is done + +Current state: + +- recall is explicit and targeted +- assistant guidance is now better + +This is intentionally not earlier in the order because: + +- auto-recall before structured events and better observability is harder to + trust +- over-eager recall can create noise, token waste, and hard-to-debug behavior + +If done later, it should be narrow: + +- only when current-thread context is obviously insufficient +- only with targeted queries +- only after the human can clearly see that memory was consulted + +## Non-Goals + +Do not do these in the next lane unless requirements change: + +- MCP-based memory exposure +- a second competing memory backend +- broad automatic recall on every turn +- large static memory dumps into the prompt +- major UI chrome like a full separate memory sidebar + +## Acceptance Gates For The Next Meaningful Lane + +The next follow-up lane should count as complete only if: + +- memory events are structured rather than inferred from strings +- the human sees a single coherent memory card per operation +- assistant-triggered recall is visible to the human +- the UI still stays aligned between `tui` and `tui_app_server` + +## Recommendation + +If choosing only one next lane, do this: + +- implement structured memory protocol events and use them to replace the + current string-parsing visual UI path + +That is the best leverage point because it improves: + +- UI clarity +- assistant transparency +- maintainability +- long-term extensibility + +If choosing two lanes, do these in order: + +1. structured memory events +2. payload-quality backlog (`post_tool_use` caps + `pre_tool_use` filtering) From 713dbb38042688bee1eb811abbf63958ba359921 Mon Sep 17 00:00:00 2001 From: Eric Juta Date: Mon, 30 Mar 2026 10:21:33 +0100 Subject: [PATCH 37/45] Add structured memory operation events Replace string-only memory outcome signaling with a structured MemoryOperation event and app-server notification, regenerate the protocol schemas, and update the memory follow-up docs. Co-authored-by: Codex --- .../schema/json/ServerNotification.json | 76 ++++++ .../codex_app_server_protocol.schemas.json | 78 ++++++ .../codex_app_server_protocol.v2.schemas.json | 78 ++++++ .../json/v2/MemoryOperationNotification.json | 60 +++++ .../schema/typescript/ServerNotification.ts | 3 +- .../typescript/v2/MemoryOperationKind.ts | 5 + .../v2/MemoryOperationNotification.ts | 7 + .../typescript/v2/MemoryOperationStatus.ts | 5 + .../schema/typescript/v2/index.ts | 3 + .../src/protocol/common.rs | 1 + .../src/protocol/thread_history.rs | 1 + .../app-server-protocol/src/protocol/v2.rs | 55 +++++ codex-rs/app-server/README.md | 5 +- .../app-server/src/bespoke_event_handling.rs | 17 ++ codex-rs/core/src/codex.rs | 231 +++++++++++------- codex-rs/docs/agentmemory_followup_spec.md | 7 + codex-rs/mcp-server/src/codex_tool_runner.rs | 1 + codex-rs/protocol/src/items.rs | 19 ++ codex-rs/protocol/src/protocol.rs | 16 ++ codex-rs/rollout/src/policy.rs | 1 + 20 files changed, 578 insertions(+), 91 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/json/v2/MemoryOperationNotification.json create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/MemoryOperationKind.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/MemoryOperationNotification.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/MemoryOperationStatus.ts diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index f89ba681c..e994a9403 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -1627,6 +1627,62 @@ ], "type": "object" }, + "MemoryOperationKind": { + "enum": [ + "recall", + "update", + "drop" + ], + "type": "string" + }, + "MemoryOperationNotification": { + "properties": { + "contextInjected": { + "type": "boolean" + }, + "detail": { + "type": [ + "string", + "null" + ] + }, + "operation": { + "$ref": "#/definitions/MemoryOperationKind" + }, + "query": { + "type": [ + "string", + "null" + ] + }, + "status": { + "$ref": "#/definitions/MemoryOperationStatus" + }, + "summary": { + "type": "string" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "contextInjected", + "operation", + "status", + "summary", + "threadId" + ], + "type": "object" + }, + "MemoryOperationStatus": { + "enum": [ + "pending", + "ready", + "empty", + "error" + ], + "type": "string" + }, "MessagePhase": { "description": "Classifies an assistant message as interim commentary or final answer text.\n\nProviders do not emit this consistently, so callers must treat `None` as \"phase unknown\" and keep compatibility behavior for legacy models.", "oneOf": [ @@ -4165,6 +4221,26 @@ "title": "Item/completedNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "thread/memory/operation" + ], + "title": "Thread/memory/operationNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/MemoryOperationNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/memory/operationNotification", + "type": "object" + }, { "properties": { "method": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 72ad8d9d7..c0aa5f122 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -3948,6 +3948,26 @@ "title": "Item/completedNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "thread/memory/operation" + ], + "title": "Thread/memory/operationNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/MemoryOperationNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/memory/operationNotification", + "type": "object" + }, { "properties": { "method": { @@ -9130,6 +9150,64 @@ ], "type": "object" }, + "MemoryOperationKind": { + "enum": [ + "recall", + "update", + "drop" + ], + "type": "string" + }, + "MemoryOperationNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "contextInjected": { + "type": "boolean" + }, + "detail": { + "type": [ + "string", + "null" + ] + }, + "operation": { + "$ref": "#/definitions/v2/MemoryOperationKind" + }, + "query": { + "type": [ + "string", + "null" + ] + }, + "status": { + "$ref": "#/definitions/v2/MemoryOperationStatus" + }, + "summary": { + "type": "string" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "contextInjected", + "operation", + "status", + "summary", + "threadId" + ], + "title": "MemoryOperationNotification", + "type": "object" + }, + "MemoryOperationStatus": { + "enum": [ + "pending", + "ready", + "empty", + "error" + ], + "type": "string" + }, "MergeStrategy": { "enum": [ "replace", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 8b92e328d..7d2ee2492 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -5845,6 +5845,64 @@ ], "type": "object" }, + "MemoryOperationKind": { + "enum": [ + "recall", + "update", + "drop" + ], + "type": "string" + }, + "MemoryOperationNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "contextInjected": { + "type": "boolean" + }, + "detail": { + "type": [ + "string", + "null" + ] + }, + "operation": { + "$ref": "#/definitions/MemoryOperationKind" + }, + "query": { + "type": [ + "string", + "null" + ] + }, + "status": { + "$ref": "#/definitions/MemoryOperationStatus" + }, + "summary": { + "type": "string" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "contextInjected", + "operation", + "status", + "summary", + "threadId" + ], + "title": "MemoryOperationNotification", + "type": "object" + }, + "MemoryOperationStatus": { + "enum": [ + "pending", + "ready", + "empty", + "error" + ], + "type": "string" + }, "MergeStrategy": { "enum": [ "replace", @@ -8551,6 +8609,26 @@ "title": "Item/completedNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "thread/memory/operation" + ], + "title": "Thread/memory/operationNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/MemoryOperationNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/memory/operationNotification", + "type": "object" + }, { "properties": { "method": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/MemoryOperationNotification.json b/codex-rs/app-server-protocol/schema/json/v2/MemoryOperationNotification.json new file mode 100644 index 000000000..2ddbbf72c --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/MemoryOperationNotification.json @@ -0,0 +1,60 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "MemoryOperationKind": { + "enum": [ + "recall", + "update", + "drop" + ], + "type": "string" + }, + "MemoryOperationStatus": { + "enum": [ + "pending", + "ready", + "empty", + "error" + ], + "type": "string" + } + }, + "properties": { + "contextInjected": { + "type": "boolean" + }, + "detail": { + "type": [ + "string", + "null" + ] + }, + "operation": { + "$ref": "#/definitions/MemoryOperationKind" + }, + "query": { + "type": [ + "string", + "null" + ] + }, + "status": { + "$ref": "#/definitions/MemoryOperationStatus" + }, + "summary": { + "type": "string" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "contextInjected", + "operation", + "status", + "summary", + "threadId" + ], + "title": "MemoryOperationNotification", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts b/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts index 85ebe847f..70db58062 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts @@ -25,6 +25,7 @@ import type { ItemStartedNotification } from "./v2/ItemStartedNotification"; import type { McpServerOauthLoginCompletedNotification } from "./v2/McpServerOauthLoginCompletedNotification"; import type { McpServerStatusUpdatedNotification } from "./v2/McpServerStatusUpdatedNotification"; import type { McpToolCallProgressNotification } from "./v2/McpToolCallProgressNotification"; +import type { MemoryOperationNotification } from "./v2/MemoryOperationNotification"; import type { ModelReroutedNotification } from "./v2/ModelReroutedNotification"; import type { PlanDeltaNotification } from "./v2/PlanDeltaNotification"; import type { RawResponseItemCompletedNotification } from "./v2/RawResponseItemCompletedNotification"; @@ -57,4 +58,4 @@ import type { WindowsWorldWritableWarningNotification } from "./v2/WindowsWorldW /** * Notification sent from the server to the client. */ -export type ServerNotification = { "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/status/changed", "params": ThreadStatusChangedNotification } | { "method": "thread/archived", "params": ThreadArchivedNotification } | { "method": "thread/unarchived", "params": ThreadUnarchivedNotification } | { "method": "thread/closed", "params": ThreadClosedNotification } | { "method": "skills/changed", "params": SkillsChangedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "hook/started", "params": HookStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "hook/completed", "params": HookCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/autoApprovalReview/started", "params": ItemGuardianApprovalReviewStartedNotification } | { "method": "item/autoApprovalReview/completed", "params": ItemGuardianApprovalReviewCompletedNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "command/exec/outputDelta", "params": CommandExecOutputDeltaNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "serverRequest/resolved", "params": ServerRequestResolvedNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "mcpServer/startupStatus/updated", "params": McpServerStatusUpdatedNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "fs/changed", "params": FsChangedNotification } | { "method": "item/reasoning/summaryTextDelta", "params": ReasoningSummaryTextDeltaNotification } | { "method": "item/reasoning/summaryPartAdded", "params": ReasoningSummaryPartAddedNotification } | { "method": "item/reasoning/textDelta", "params": ReasoningTextDeltaNotification } | { "method": "thread/compacted", "params": ContextCompactedNotification } | { "method": "model/rerouted", "params": ModelReroutedNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "fuzzyFileSearch/sessionUpdated", "params": FuzzyFileSearchSessionUpdatedNotification } | { "method": "fuzzyFileSearch/sessionCompleted", "params": FuzzyFileSearchSessionCompletedNotification } | { "method": "thread/realtime/started", "params": ThreadRealtimeStartedNotification } | { "method": "thread/realtime/itemAdded", "params": ThreadRealtimeItemAddedNotification } | { "method": "thread/realtime/transcriptUpdated", "params": ThreadRealtimeTranscriptUpdatedNotification } | { "method": "thread/realtime/outputAudio/delta", "params": ThreadRealtimeOutputAudioDeltaNotification } | { "method": "thread/realtime/error", "params": ThreadRealtimeErrorNotification } | { "method": "thread/realtime/closed", "params": ThreadRealtimeClosedNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "windowsSandbox/setupCompleted", "params": WindowsSandboxSetupCompletedNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification }; +export type ServerNotification = { "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/status/changed", "params": ThreadStatusChangedNotification } | { "method": "thread/archived", "params": ThreadArchivedNotification } | { "method": "thread/unarchived", "params": ThreadUnarchivedNotification } | { "method": "thread/closed", "params": ThreadClosedNotification } | { "method": "skills/changed", "params": SkillsChangedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "hook/started", "params": HookStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "hook/completed", "params": HookCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/autoApprovalReview/started", "params": ItemGuardianApprovalReviewStartedNotification } | { "method": "item/autoApprovalReview/completed", "params": ItemGuardianApprovalReviewCompletedNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "thread/memory/operation", "params": MemoryOperationNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "command/exec/outputDelta", "params": CommandExecOutputDeltaNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "serverRequest/resolved", "params": ServerRequestResolvedNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "mcpServer/startupStatus/updated", "params": McpServerStatusUpdatedNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "fs/changed", "params": FsChangedNotification } | { "method": "item/reasoning/summaryTextDelta", "params": ReasoningSummaryTextDeltaNotification } | { "method": "item/reasoning/summaryPartAdded", "params": ReasoningSummaryPartAddedNotification } | { "method": "item/reasoning/textDelta", "params": ReasoningTextDeltaNotification } | { "method": "thread/compacted", "params": ContextCompactedNotification } | { "method": "model/rerouted", "params": ModelReroutedNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "fuzzyFileSearch/sessionUpdated", "params": FuzzyFileSearchSessionUpdatedNotification } | { "method": "fuzzyFileSearch/sessionCompleted", "params": FuzzyFileSearchSessionCompletedNotification } | { "method": "thread/realtime/started", "params": ThreadRealtimeStartedNotification } | { "method": "thread/realtime/itemAdded", "params": ThreadRealtimeItemAddedNotification } | { "method": "thread/realtime/transcriptUpdated", "params": ThreadRealtimeTranscriptUpdatedNotification } | { "method": "thread/realtime/outputAudio/delta", "params": ThreadRealtimeOutputAudioDeltaNotification } | { "method": "thread/realtime/error", "params": ThreadRealtimeErrorNotification } | { "method": "thread/realtime/closed", "params": ThreadRealtimeClosedNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "windowsSandbox/setupCompleted", "params": WindowsSandboxSetupCompletedNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/MemoryOperationKind.ts b/codex-rs/app-server-protocol/schema/typescript/v2/MemoryOperationKind.ts new file mode 100644 index 000000000..a093a7f67 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/MemoryOperationKind.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type MemoryOperationKind = "recall" | "update" | "drop"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/MemoryOperationNotification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/MemoryOperationNotification.ts new file mode 100644 index 000000000..41aac16f1 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/MemoryOperationNotification.ts @@ -0,0 +1,7 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { MemoryOperationKind } from "./MemoryOperationKind"; +import type { MemoryOperationStatus } from "./MemoryOperationStatus"; + +export type MemoryOperationNotification = { threadId: string, operation: MemoryOperationKind, status: MemoryOperationStatus, query: string | null, summary: string, detail: string | null, contextInjected: boolean, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/MemoryOperationStatus.ts b/codex-rs/app-server-protocol/schema/typescript/v2/MemoryOperationStatus.ts new file mode 100644 index 000000000..b611b9a4b --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/MemoryOperationStatus.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type MemoryOperationStatus = "pending" | "ready" | "empty" | "error"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index 09207eff6..038ba1092 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -188,6 +188,9 @@ export type { McpToolCallResult } from "./McpToolCallResult"; export type { McpToolCallStatus } from "./McpToolCallStatus"; export type { MemoryCitation } from "./MemoryCitation"; export type { MemoryCitationEntry } from "./MemoryCitationEntry"; +export type { MemoryOperationKind } from "./MemoryOperationKind"; +export type { MemoryOperationNotification } from "./MemoryOperationNotification"; +export type { MemoryOperationStatus } from "./MemoryOperationStatus"; export type { MergeStrategy } from "./MergeStrategy"; export type { Model } from "./Model"; export type { ModelAvailabilityNux } from "./ModelAvailabilityNux"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index fd2025e87..fb19fcd04 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -906,6 +906,7 @@ server_notification_definitions! { ItemGuardianApprovalReviewStarted => "item/autoApprovalReview/started" (v2::ItemGuardianApprovalReviewStartedNotification), ItemGuardianApprovalReviewCompleted => "item/autoApprovalReview/completed" (v2::ItemGuardianApprovalReviewCompletedNotification), ItemCompleted => "item/completed" (v2::ItemCompletedNotification), + MemoryOperation => "thread/memory/operation" (v2::MemoryOperationNotification), /// This event is internal-only. Used by Codex Cloud. RawResponseItemCompleted => "rawResponseItem/completed" (v2::RawResponseItemCompletedNotification), AgentMessageDelta => "item/agentMessage/delta" (v2::AgentMessageDeltaNotification), diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs index 48fa56d68..4173061a1 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -187,6 +187,7 @@ impl ThreadHistoryBuilder { EventMsg::ItemStarted(payload) => self.handle_item_started(payload), EventMsg::ItemCompleted(payload) => self.handle_item_completed(payload), EventMsg::HookStarted(_) | EventMsg::HookCompleted(_) => {} + EventMsg::MemoryOperation(_) => {} EventMsg::Error(payload) => self.handle_error(payload), EventMsg::TokenCount(_) => {} EventMsg::ThreadRolledBack(payload) => self.handle_thread_rollback(payload), diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 4b7da537a..bb7630958 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -26,6 +26,8 @@ use codex_protocol::config_types::Verbosity; use codex_protocol::config_types::WebSearchMode; use codex_protocol::config_types::WebSearchToolConfig; use codex_protocol::items::AgentMessageContent as CoreAgentMessageContent; +use codex_protocol::items::MemoryOperationKind as CoreMemoryOperationKind; +use codex_protocol::items::MemoryOperationStatus as CoreMemoryOperationStatus; use codex_protocol::items::TurnItem as CoreTurnItem; use codex_protocol::mcp::Resource as McpResource; use codex_protocol::mcp::ResourceTemplate as McpResourceTemplate; @@ -3011,6 +3013,46 @@ pub struct ThreadMemoryRecallParams { #[ts(export_to = "v2/")] pub struct ThreadMemoryRecallResponse {} +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(rename_all = "snake_case", export_to = "v2/")] +pub enum MemoryOperationKind { + Recall, + Update, + Drop, +} + +impl From for MemoryOperationKind { + fn from(value: CoreMemoryOperationKind) -> Self { + match value { + CoreMemoryOperationKind::Recall => Self::Recall, + CoreMemoryOperationKind::Update => Self::Update, + CoreMemoryOperationKind::Drop => Self::Drop, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(rename_all = "snake_case", export_to = "v2/")] +pub enum MemoryOperationStatus { + Pending, + Ready, + Empty, + Error, +} + +impl From for MemoryOperationStatus { + fn from(value: CoreMemoryOperationStatus) -> Self { + match value { + CoreMemoryOperationStatus::Pending => Self::Pending, + CoreMemoryOperationStatus::Ready => Self::Ready, + CoreMemoryOperationStatus::Empty => Self::Empty, + CoreMemoryOperationStatus::Error => Self::Error, + } + } +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -5018,6 +5060,19 @@ pub struct ItemCompletedNotification { pub turn_id: String, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct MemoryOperationNotification { + pub thread_id: String, + pub operation: MemoryOperationKind, + pub status: MemoryOperationStatus, + pub query: Option, + pub summary: String, + pub detail: Option, + pub context_injected: bool, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 8c33c7807..ac1de9aef 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -79,7 +79,7 @@ Use the thread APIs to create, list, or archive conversations. Drive a conversat - Start (or resume) a thread: Call `thread/start` to open a fresh conversation. The response returns the thread object and you’ll also get a `thread/started` notification. If you’re continuing an existing conversation, call `thread/resume` with its ID instead. If you want to branch from an existing conversation, call `thread/fork` to create a new thread id with copied history. Like `thread/start`, `thread/fork` also accepts `ephemeral: true` for an in-memory temporary thread. The returned `thread.ephemeral` flag tells you whether the session is intentionally in-memory only; when it is `true`, `thread.path` is `null`. - Begin a turn: To send user input, call `turn/start` with the target `threadId` and the user's input. Optional fields let you override model, cwd, sandbox policy, approval policy, approvals reviewer, etc. This immediately returns the new turn object. The app-server emits `turn/started` when that turn actually begins running. -- Stream events: After `turn/start`, keep reading JSON-RPC notifications on stdout. You’ll see `item/started`, `item/completed`, deltas like `item/agentMessage/delta`, tool progress, etc. These represent streaming model output plus any side effects (commands, tool calls, reasoning notes). +- Stream events: After `turn/start`, keep reading JSON-RPC notifications on stdout. You’ll see `item/started`, `item/completed`, deltas like `item/agentMessage/delta`, tool progress, memory-operation notifications, etc. These represent streaming model output plus any side effects (commands, tool calls, reasoning notes). - Finish the turn: When the model is done (or the turn is interrupted via making the `turn/interrupt` call), the server sends `turn/completed` with the final turn state and token usage. ## Initialization @@ -465,7 +465,7 @@ Use the thread-scoped memory methods to mirror the legacy TUI slash commands: - `thread/memory/update` triggers a backend-specific sync/consolidation pass. - `thread/memory/recall` retrieves memory context and injects it into the thread as developer instructions. -All three requests return immediately with `{}`. Result details surface through the normal thread event stream as warning/error items, just like the equivalent core ops. +All three requests return immediately with `{}`. Result details surface through the thread event stream as `thread/memory/operation` notifications carrying the structured operation, status, optional query, summary, optional detail, and whether recalled context was injected into the thread. ```json { "method": "thread/memory/drop", "id": 27, "params": { "threadId": "thr_b" } } @@ -942,6 +942,7 @@ All items emit shared lifecycle events: - `item/completed` — sends the final `item` once that work itself finishes (for example, after a tool call or message completes); treat this as the authoritative execution/result state. - `item/autoApprovalReview/started` — [UNSTABLE] temporary guardian notification carrying `{threadId, turnId, targetItemId, review, action?}` when guardian approval review begins. This shape is expected to change soon. - `item/autoApprovalReview/completed` — [UNSTABLE] temporary guardian notification carrying `{threadId, turnId, targetItemId, review, action?}` when guardian approval review resolves. This shape is expected to change soon. +- `thread/memory/operation` — sends structured outcomes for `thread/memory/drop`, `thread/memory/update`, and `thread/memory/recall` with `{threadId, operation, status, query?, summary, detail?, contextInjected}`. `review` is [UNSTABLE] and currently has `{status, riskScore?, riskLevel?, rationale?}`, where `status` is one of `inProgress`, `approved`, `denied`, or `aborted`. `action` is the guardian action summary payload from core when available and is intended to support temporary standalone pending-review UI. These notifications are separate from the target item's own `item/completed` lifecycle and are intentionally temporary while the guardian app protocol is still being designed. diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 15484d3d5..189f6fc73 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -62,6 +62,7 @@ use codex_app_server_protocol::McpServerStatusUpdatedNotification; use codex_app_server_protocol::McpToolCallError; use codex_app_server_protocol::McpToolCallResult; use codex_app_server_protocol::McpToolCallStatus; +use codex_app_server_protocol::MemoryOperationNotification; use codex_app_server_protocol::ModelReroutedNotification; use codex_app_server_protocol::NetworkApprovalContext as V2NetworkApprovalContext; use codex_app_server_protocol::NetworkPolicyAmendment as V2NetworkPolicyAmendment; @@ -342,6 +343,22 @@ pub(crate) async fn apply_bespoke_event_handling( } } EventMsg::Warning(_warning_event) => {} + EventMsg::MemoryOperation(event) => { + if let ApiVersion::V2 = api_version { + let notification = MemoryOperationNotification { + thread_id: conversation_id.to_string(), + operation: event.operation.into(), + status: event.status.into(), + query: event.query, + summary: event.summary, + detail: event.detail, + context_injected: event.context_injected, + }; + outgoing + .send_server_notification(ServerNotification::MemoryOperation(notification)) + .await; + } + } EventMsg::GuardianAssessment(assessment) => { if let ApiVersion::V2 = api_version { let notification = guardian_auto_approval_review_notification( diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 746a44411..5c0026852 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -4599,6 +4599,8 @@ mod handlers { use crate::tasks::UserShellCommandTask; use crate::tasks::execute_user_shell_command; use codex_protocol::custom_prompts::CustomPrompt; + use codex_protocol::items::MemoryOperationKind; + use codex_protocol::items::MemoryOperationStatus; use codex_protocol::models::DeveloperInstructions; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::CodexErrorInfo; @@ -4609,6 +4611,7 @@ mod handlers { use codex_protocol::protocol::ListCustomPromptsResponseEvent; use codex_protocol::protocol::ListSkillsResponseEvent; use codex_protocol::protocol::McpServerRefreshConfig; + use codex_protocol::protocol::MemoryOperationEvent; use codex_protocol::protocol::Op; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewRequest; @@ -5120,26 +5123,57 @@ mod handlers { .await; } + async fn send_memory_operation_event( + sess: &Session, + sub_id: &str, + operation: MemoryOperationKind, + status: MemoryOperationStatus, + query: Option, + summary: String, + detail: Option, + context_injected: bool, + ) { + sess.send_event_raw(Event { + id: sub_id.to_string(), + msg: EventMsg::MemoryOperation(MemoryOperationEvent { + operation, + status, + query, + summary, + detail, + context_injected, + }), + }) + .await; + } + pub async fn drop_memories(sess: &Arc, config: &Arc, sub_id: String) { if config.memories.backend == crate::config::types::MemoryBackend::Agentmemory { let adapter = crate::agentmemory::AgentmemoryAdapter::new(); if let Err(e) = adapter.drop_memories().await { - sess.send_event_raw(Event { - id: sub_id, - msg: EventMsg::Error(ErrorEvent { - message: format!("Agentmemory clear failed: {e}"), - codex_error_info: Some(CodexErrorInfo::Other), - }), - }) + send_memory_operation_event( + sess, + &sub_id, + MemoryOperationKind::Drop, + MemoryOperationStatus::Error, + /*query*/ None, + "Memory drop failed.".to_string(), + Some(e.to_string()), + /*context_injected*/ false, + ) .await; return; } - sess.send_event_raw(Event { - id: sub_id, - msg: EventMsg::Warning(WarningEvent { - message: "Cleared Agentmemory contents.".to_string(), - }), - }) + send_memory_operation_event( + sess, + &sub_id, + MemoryOperationKind::Drop, + MemoryOperationStatus::Ready, + /*query*/ None, + "Cleared Agentmemory contents.".to_string(), + /*detail*/ None, + /*context_injected*/ false, + ) .await; return; } @@ -5163,26 +5197,33 @@ mod handlers { } if errors.is_empty() { - sess.send_event_raw(Event { - id: sub_id, - msg: EventMsg::Warning(WarningEvent { - message: format!( - "Memory drop completed. Cleared memory rows from the state db and removed stored memory files at {}.", - memory_root.display() - ), - }), - }) + send_memory_operation_event( + sess, + &sub_id, + MemoryOperationKind::Drop, + MemoryOperationStatus::Ready, + /*query*/ None, + "Dropped stored memories for this workspace.".to_string(), + Some(format!( + "Cleared memory rows from the state db and removed stored memory files at {}.", + memory_root.display() + )), + /*context_injected*/ false, + ) .await; return; } - sess.send_event_raw(Event { - id: sub_id, - msg: EventMsg::Error(ErrorEvent { - message: format!("Memory drop completed with errors: {}", errors.join("; ")), - codex_error_info: Some(CodexErrorInfo::Other), - }), - }) + send_memory_operation_event( + sess, + &sub_id, + MemoryOperationKind::Drop, + MemoryOperationStatus::Error, + /*query*/ None, + "Memory drop completed with errors.".to_string(), + Some(errors.join("; ")), + /*context_injected*/ false, + ) .await; } @@ -5195,22 +5236,31 @@ mod handlers { if config.memories.backend == crate::config::types::MemoryBackend::Agentmemory { let adapter = crate::agentmemory::AgentmemoryAdapter::new(); if let Err(e) = adapter.update_memories().await { - sess.send_event_raw(Event { - id: sub_id.clone(), - msg: EventMsg::Error(ErrorEvent { - message: format!("Agentmemory sync failed: {e}"), - codex_error_info: Some(CodexErrorInfo::Other), - }), - }) + send_memory_operation_event( + sess, + &sub_id, + MemoryOperationKind::Update, + MemoryOperationStatus::Error, + /*query*/ None, + "Memory update failed.".to_string(), + Some(e.to_string()), + /*context_injected*/ false, + ) .await; return; } - sess.send_event_raw(Event { - id: sub_id.clone(), - msg: EventMsg::Warning(WarningEvent { - message: "Agentmemory sync triggered. Updated observations will appear in future memory recalls once consolidation completes.".to_string(), - }), - }) + send_memory_operation_event( + sess, + &sub_id, + MemoryOperationKind::Update, + MemoryOperationStatus::Ready, + /*query*/ None, + "Agentmemory sync triggered.".to_string(), + Some( + "Updated observations will appear in future memory recalls once consolidation completes.".to_string(), + ), + /*context_injected*/ false, + ) .await; return; } @@ -5219,13 +5269,16 @@ mod handlers { crate::memories::start_memories_startup_task(sess, Arc::clone(config), &session_source); } - sess.send_event_raw(Event { - id: sub_id.clone(), - msg: EventMsg::Warning(WarningEvent { - message: "Memory update triggered. Consolidation is running in the background." - .to_string(), - }), - }) + send_memory_operation_event( + sess, + &sub_id, + MemoryOperationKind::Update, + MemoryOperationStatus::Ready, + /*query*/ None, + "Memory update triggered.".to_string(), + Some("Consolidation is running in the background.".to_string()), + /*context_injected*/ false, + ) .await; } @@ -5236,12 +5289,16 @@ mod handlers { query: Option, ) { if config.memories.backend != crate::config::types::MemoryBackend::Agentmemory { - sess.send_event_raw(Event { - id: sub_id, - msg: EventMsg::Warning(WarningEvent { - message: "Memory recall requires agentmemory backend.".to_string(), - }), - }) + send_memory_operation_event( + sess, + &sub_id, + MemoryOperationKind::Recall, + MemoryOperationStatus::Error, + query, + "Memory recall requires agentmemory backend.".to_string(), + /*detail*/ None, + /*context_injected*/ false, + ) .await; return; } @@ -5262,45 +5319,42 @@ mod handlers { .into(); sess.record_conversation_items(&turn_context, std::slice::from_ref(&message)) .await; - let query_summary = query - .as_deref() - .map(str::trim) - .filter(|query| !query.is_empty()) - .map(|query| format!(" for query: {query}")) - .unwrap_or_default(); - sess.send_event_raw(Event { - id: sub_id, - msg: EventMsg::Warning(WarningEvent { - message: format!( - "Memory context recalled{query_summary} and injected into this thread:\n\n{context}" - ), - }), - }) + send_memory_operation_event( + sess, + &sub_id, + MemoryOperationKind::Recall, + MemoryOperationStatus::Ready, + query, + "Recalled memory context and injected it into the current thread.".to_string(), + Some(context), + /*context_injected*/ true, + ) .await; } Ok(_) => { - let query_summary = query - .as_deref() - .map(str::trim) - .filter(|query| !query.is_empty()) - .map(|query| format!(" for query: {query}")) - .unwrap_or_default(); - sess.send_event_raw(Event { - id: sub_id, - msg: EventMsg::Warning(WarningEvent { - message: format!("No relevant memory context found{query_summary}."), - }), - }) + send_memory_operation_event( + sess, + &sub_id, + MemoryOperationKind::Recall, + MemoryOperationStatus::Empty, + query, + "No relevant memory context was found.".to_string(), + /*detail*/ None, + /*context_injected*/ false, + ) .await; } Err(e) => { - sess.send_event_raw(Event { - id: sub_id, - msg: EventMsg::Error(ErrorEvent { - message: format!("Memory recall failed: {e}"), - codex_error_info: Some(CodexErrorInfo::Other), - }), - }) + send_memory_operation_event( + sess, + &sub_id, + MemoryOperationKind::Recall, + MemoryOperationStatus::Error, + query, + "Memory recall failed.".to_string(), + Some(e.to_string()), + /*context_injected*/ false, + ) .await; } } @@ -7046,6 +7100,7 @@ fn realtime_text_for_event(msg: &EventMsg) -> Option { }, EventMsg::Error(_) | EventMsg::Warning(_) + | EventMsg::MemoryOperation(_) | EventMsg::RealtimeConversationStarted(_) | EventMsg::RealtimeConversationRealtime(_) | EventMsg::RealtimeConversationClosed(_) diff --git a/codex-rs/docs/agentmemory_followup_spec.md b/codex-rs/docs/agentmemory_followup_spec.md index 2bb293445..189da439c 100644 --- a/codex-rs/docs/agentmemory_followup_spec.md +++ b/codex-rs/docs/agentmemory_followup_spec.md @@ -5,6 +5,12 @@ Proposed follow-up backlog after the runtime-surface, proactive-guidance, and visual-memory-UI lanes. +Priority 1 is now implemented for the human-triggered memory control plane: +structured memory events replace string-matched warning/error parsing for +`/memory-recall`, `/memory-update`, and `/memory-drop` across both TUIs. +The remaining backlog starts at in-place completion updates and +assistant-triggered memory visibility. + This document exists to answer one practical question: - what is still worth doing after the current `agentmemory` integration work, @@ -20,6 +26,7 @@ The fork already has: - human-facing `/memory-recall`, `/memory-update`, and `/memory-drop` - proactive runtime guidance for when the assistant should use recall - dedicated visual memory history cells in both TUIs +- structured memory outcome events for human-triggered recall/update/drop That means the system is functionally good. What remains is mostly structural cleanup, richer human visibility, and better retrieval/capture quality. diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index 780a80803..8e66a97ad 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -368,6 +368,7 @@ async fn run_codex_tool_session_inner( | EventMsg::ReasoningContentDelta(_) | EventMsg::ReasoningRawContentDelta(_) | EventMsg::SkillsUpdateAvailable + | EventMsg::MemoryOperation(_) | EventMsg::UndoStarted(_) | EventMsg::UndoCompleted(_) | EventMsg::ExitedReviewMode(_) diff --git a/codex-rs/protocol/src/items.rs b/codex-rs/protocol/src/items.rs index 36c8cdbae..9f7ebfe59 100644 --- a/codex-rs/protocol/src/items.rs +++ b/codex-rs/protocol/src/items.rs @@ -126,6 +126,25 @@ pub struct ImageGenerationItem { pub saved_path: Option, } +#[derive(Debug, Clone, Copy, Deserialize, Serialize, TS, JsonSchema, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +#[ts(rename_all = "snake_case")] +pub enum MemoryOperationKind { + Recall, + Update, + Drop, +} + +#[derive(Debug, Clone, Copy, Deserialize, Serialize, TS, JsonSchema, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +#[ts(rename_all = "snake_case")] +pub enum MemoryOperationStatus { + Pending, + Ready, + Empty, + Error, +} + #[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)] pub struct ContextCompactionItem { pub id: String, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 6b0b43fdf..567b72544 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -28,6 +28,8 @@ use crate::dynamic_tools::DynamicToolCallOutputContentItem; use crate::dynamic_tools::DynamicToolCallRequest; use crate::dynamic_tools::DynamicToolResponse; use crate::dynamic_tools::DynamicToolSpec; +use crate::items::MemoryOperationKind; +use crate::items::MemoryOperationStatus; use crate::items::TurnItem; use crate::mcp::CallToolResult; use crate::mcp::RequestId; @@ -1217,6 +1219,9 @@ pub enum EventMsg { /// indicates the turn continued but the user should still be notified. Warning(WarningEvent), + /// Structured memory operation outcome for human-visible recall/update/drop actions. + MemoryOperation(MemoryOperationEvent), + /// Realtime conversation lifecycle start event. RealtimeConversationStarted(RealtimeConversationStartedEvent), @@ -1842,6 +1847,17 @@ pub struct WarningEvent { pub message: String, } +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub struct MemoryOperationEvent { + pub operation: MemoryOperationKind, + pub status: MemoryOperationStatus, + pub query: Option, + pub summary: String, + pub detail: Option, + pub context_injected: bool, +} + #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "snake_case")] #[ts(rename_all = "snake_case")] diff --git a/codex-rs/rollout/src/policy.rs b/codex-rs/rollout/src/policy.rs index c4b4b8c33..5e5af4460 100644 --- a/codex-rs/rollout/src/policy.rs +++ b/codex-rs/rollout/src/policy.rs @@ -105,6 +105,7 @@ fn event_msg_persistence_mode(ev: &EventMsg) -> Option { | EventMsg::TurnAborted(_) | EventMsg::TurnStarted(_) | EventMsg::TurnComplete(_) + | EventMsg::MemoryOperation(_) | EventMsg::ImageGenerationEnd(_) => Some(EventPersistenceMode::Limited), EventMsg::ItemCompleted(event) => { // Plan items are derived from streaming tags and are not part of the From c23f19e3876529e0fada396424e36acdd529001c Mon Sep 17 00:00:00 2001 From: Eric Juta Date: Mon, 30 Mar 2026 10:21:49 +0100 Subject: [PATCH 38/45] Use structured memory events in the TUIs Replace memory warning/error string parsing with structured memory event rendering in both TUI stacks and add focused regression coverage for the new event and notification paths. Co-authored-by: Codex --- codex-rs/tui/src/chatwidget.rs | 20 +- codex-rs/tui/src/chatwidget/tests.rs | 32 ++++ codex-rs/tui/src/history_cell.rs | 174 ++++-------------- .../src/app/app_server_adapter.rs | 40 ++++ codex-rs/tui_app_server/src/chatwidget.rs | 31 ++-- .../tui_app_server/src/chatwidget/tests.rs | 39 ++++ codex-rs/tui_app_server/src/history_cell.rs | 144 +++++---------- 7 files changed, 230 insertions(+), 250 deletions(-) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 5feecd6cd..5928f7d5a 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -135,6 +135,7 @@ use codex_protocol::protocol::McpStartupStatus; use codex_protocol::protocol::McpStartupUpdateEvent; use codex_protocol::protocol::McpToolCallBeginEvent; use codex_protocol::protocol::McpToolCallEndEvent; +use codex_protocol::protocol::MemoryOperationEvent; use codex_protocol::protocol::Op; use codex_protocol::protocol::PatchApplyBeginEvent; use codex_protocol::protocol::RateLimitSnapshot; @@ -2205,11 +2206,7 @@ impl ChatWidget { fn on_error(&mut self, message: String) { self.submit_pending_steers_after_interrupt = false; self.finalize_turn(); - if let Some(cell) = history_cell::memory_error_event(&message) { - self.add_to_history(cell); - } else { - self.add_to_history(history_cell::new_error_event(message)); - } + self.add_to_history(history_cell::new_error_event(message)); self.request_redraw(); // After an error ends the turn, try sending the next queued input. @@ -2217,12 +2214,12 @@ impl ChatWidget { } fn on_warning(&mut self, message: impl Into) { - let message = message.into(); - if let Some(cell) = history_cell::memory_warning_event(&message) { - self.add_to_history(cell); - } else { - self.add_to_history(history_cell::new_warning_event(message)); - } + self.add_to_history(history_cell::new_warning_event(message.into())); + self.request_redraw(); + } + + fn on_memory_operation(&mut self, event: MemoryOperationEvent) { + self.add_to_history(history_cell::new_memory_operation_event(event)); self.request_redraw(); } @@ -5556,6 +5553,7 @@ impl ChatWidget { self.on_rate_limit_snapshot(ev.rate_limits); } EventMsg::Warning(WarningEvent { message }) => self.on_warning(message), + EventMsg::MemoryOperation(event) => self.on_memory_operation(event), EventMsg::GuardianAssessment(ev) => self.on_guardian_assessment(ev), EventMsg::ModelReroute(_) => {} EventMsg::Error(ErrorEvent { diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index ce27a872d..403f01539 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -11605,6 +11605,38 @@ async fn warning_event_adds_warning_history_cell() { ); } +#[tokio::test] +async fn memory_operation_event_adds_memory_history_cell() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + chat.handle_codex_event(Event { + id: "sub-1".into(), + msg: EventMsg::MemoryOperation(codex_protocol::protocol::MemoryOperationEvent { + operation: codex_protocol::items::MemoryOperationKind::Recall, + status: codex_protocol::items::MemoryOperationStatus::Ready, + query: Some("retrieval freshness".to_string()), + summary: "Recalled memory context and injected it into the current thread.".to_string(), + detail: Some("remember this".to_string()), + context_injected: true, + }), + }); + + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 1, "expected one memory history cell"); + let rendered = lines_to_single_string(&cells[0]); + assert!( + rendered.contains("Memory Recall"), + "missing memory title: {rendered}" + ); + assert!( + rendered.contains("Query: retrieval freshness"), + "missing memory query: {rendered}" + ); + assert!( + rendered.contains("remember this"), + "missing memory detail: {rendered}" + ); +} + #[tokio::test] async fn status_line_invalid_items_warn_once() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index dc20f6c3a..a0ec337de 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -47,6 +47,8 @@ use codex_core::web_search::web_search_detail; use codex_otel::RuntimeMetricsSummary; use codex_protocol::account::PlanType; use codex_protocol::config_types::ServiceTier; +use codex_protocol::items::MemoryOperationKind; +use codex_protocol::items::MemoryOperationStatus; use codex_protocol::mcp::Resource; use codex_protocol::mcp::ResourceTemplate; use codex_protocol::models::WebSearchAction; @@ -58,6 +60,7 @@ use codex_protocol::plan_tool::UpdatePlanArgs; use codex_protocol::protocol::FileChange; use codex_protocol::protocol::McpAuthStatus; use codex_protocol::protocol::McpInvocation; +use codex_protocol::protocol::MemoryOperationEvent; use codex_protocol::protocol::SessionConfiguredEvent; use codex_protocol::request_user_input::RequestUserInputAnswer; use codex_protocol::request_user_input::RequestUserInputQuestion; @@ -1985,35 +1988,10 @@ pub(crate) fn new_error_event(message: String) -> PlainHistoryCell { const MEMORY_PREVIEW_MAX_GRAPHEMES: usize = 1_200; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum MemoryOperationKind { - Recall, - Update, - Drop, -} - -impl MemoryOperationKind { - fn title(self) -> &'static str { - match self { - Self::Recall => "Memory Recall", - Self::Update => "Memory Update", - Self::Drop => "Memory Drop", - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum MemoryOperationState { - Pending, - Success, - Empty, - Error, -} - #[derive(Debug)] pub(crate) struct MemoryHistoryCell { operation: MemoryOperationKind, - state: MemoryOperationState, + status: MemoryOperationStatus, query: Option, summary: String, detail: Option, @@ -2022,14 +2000,14 @@ pub(crate) struct MemoryHistoryCell { impl MemoryHistoryCell { fn new( operation: MemoryOperationKind, - state: MemoryOperationState, + status: MemoryOperationStatus, query: Option, summary: String, detail: Option, ) -> Self { Self { operation, - state, + status, query, summary, detail: detail @@ -2038,12 +2016,20 @@ impl MemoryHistoryCell { } } + fn title(&self) -> &'static str { + match self.operation { + MemoryOperationKind::Recall => "Memory Recall", + MemoryOperationKind::Update => "Memory Update", + MemoryOperationKind::Drop => "Memory Drop", + } + } + fn state_span(&self) -> Span<'static> { - match self.state { - MemoryOperationState::Pending => "Pending".cyan().bold(), - MemoryOperationState::Success => "Ready".green().bold(), - MemoryOperationState::Empty => "Empty".magenta().bold(), - MemoryOperationState::Error => "Error".red().bold(), + match self.status { + MemoryOperationStatus::Pending => "Pending".cyan().bold(), + MemoryOperationStatus::Ready => "Ready".green().bold(), + MemoryOperationStatus::Empty => "Empty".magenta().bold(), + MemoryOperationStatus::Error => "Error".red().bold(), } } @@ -2060,7 +2046,7 @@ impl HistoryCell for MemoryHistoryCell { let mut lines = vec![ vec![ "🧠 ".into(), - self.operation.title().bold(), + self.title().bold(), " ".into(), self.state_span(), ] @@ -2093,22 +2079,10 @@ impl HistoryCell for MemoryHistoryCell { } } -fn parse_memory_query(message: &str) -> Option { - let marker = " for query: "; - let start = message.find(marker)? + marker.len(); - let tail = &message[start..]; - let end = tail - .find(" and ") - .or_else(|| tail.find('.')) - .unwrap_or(tail.len()); - let query = tail[..end].trim(); - (!query.is_empty()).then(|| query.to_string()) -} - pub(crate) fn new_memory_recall_submission(query: Option) -> MemoryHistoryCell { MemoryHistoryCell::new( MemoryOperationKind::Recall, - MemoryOperationState::Pending, + MemoryOperationStatus::Pending, query, "Recalling memory context for the current thread.".to_string(), /*detail*/ None, @@ -2118,7 +2092,7 @@ pub(crate) fn new_memory_recall_submission(query: Option) -> MemoryHisto pub(crate) fn new_memory_update_submission() -> MemoryHistoryCell { MemoryHistoryCell::new( MemoryOperationKind::Update, - MemoryOperationState::Pending, + MemoryOperationStatus::Pending, /*query*/ None, "Requesting a memory refresh.".to_string(), /*detail*/ None, @@ -2128,7 +2102,7 @@ pub(crate) fn new_memory_update_submission() -> MemoryHistoryCell { pub(crate) fn new_memory_drop_submission() -> MemoryHistoryCell { MemoryHistoryCell::new( MemoryOperationKind::Drop, - MemoryOperationState::Pending, + MemoryOperationStatus::Pending, /*query*/ None, "Dropping stored memories for this workspace.".to_string(), /*detail*/ None, @@ -2138,93 +2112,21 @@ pub(crate) fn new_memory_drop_submission() -> MemoryHistoryCell { pub(crate) fn new_memory_recall_thread_requirement() -> MemoryHistoryCell { MemoryHistoryCell::new( MemoryOperationKind::Recall, - MemoryOperationState::Error, + MemoryOperationStatus::Error, /*query*/ None, "Start a new chat or resume an existing thread before using /memory-recall.".to_string(), /*detail*/ None, ) } -pub(crate) fn memory_warning_event(message: &str) -> Option { - if let Some((summary, detail)) = message.split_once("\n\n") - && summary.starts_with("Memory context recalled") - { - return Some(MemoryHistoryCell::new( - MemoryOperationKind::Recall, - MemoryOperationState::Success, - parse_memory_query(summary), - "Recalled memory context and injected it into the current thread.".to_string(), - Some(detail.to_string()), - )); - } - - if message.starts_with("No relevant memory context found") { - return Some(MemoryHistoryCell::new( - MemoryOperationKind::Recall, - MemoryOperationState::Empty, - parse_memory_query(message), - "No relevant memory context was found.".to_string(), - /*detail*/ None, - )); - } - - if message.starts_with("Agentmemory sync triggered") - || message.starts_with("Memory update triggered") - { - return Some(MemoryHistoryCell::new( - MemoryOperationKind::Update, - MemoryOperationState::Success, - /*query*/ None, - message.to_string(), - /*detail*/ None, - )); - } - - if message.starts_with("Memory drop completed") { - return Some(MemoryHistoryCell::new( - MemoryOperationKind::Drop, - MemoryOperationState::Success, - /*query*/ None, - message.to_string(), - /*detail*/ None, - )); - } - - None -} - -pub(crate) fn memory_error_event(message: &str) -> Option { - if let Some(detail) = message.strip_prefix("Memory recall failed: ") { - return Some(MemoryHistoryCell::new( - MemoryOperationKind::Recall, - MemoryOperationState::Error, - /*query*/ None, - "Memory recall failed.".to_string(), - Some(detail.to_string()), - )); - } - - if let Some(detail) = message.strip_prefix("Agentmemory sync failed: ") { - return Some(MemoryHistoryCell::new( - MemoryOperationKind::Update, - MemoryOperationState::Error, - /*query*/ None, - "Memory update failed.".to_string(), - Some(detail.to_string()), - )); - } - - if let Some(detail) = message.strip_prefix("Memory drop completed with errors: ") { - return Some(MemoryHistoryCell::new( - MemoryOperationKind::Drop, - MemoryOperationState::Error, - /*query*/ None, - "Memory drop completed with errors.".to_string(), - Some(detail.to_string()), - )); - } - - None +pub(crate) fn new_memory_operation_event(event: MemoryOperationEvent) -> MemoryHistoryCell { + MemoryHistoryCell::new( + event.operation, + event.status, + event.query, + event.summary, + event.detail, + ) } /// Renders a completed (or interrupted) request_user_input exchange in history. @@ -3149,10 +3051,14 @@ mod tests { #[test] fn memory_recall_result_snapshot() { - let cell = memory_warning_event( - "Memory context recalled for query: retrieval freshness and injected into this thread:\n\nremember this", - ) - .expect("expected memory warning cell"); + let cell = new_memory_operation_event(MemoryOperationEvent { + operation: MemoryOperationKind::Recall, + status: MemoryOperationStatus::Ready, + query: Some("retrieval freshness".to_string()), + summary: "Recalled memory context and injected it into the current thread.".to_string(), + detail: Some("remember this".to_string()), + context_injected: true, + }); let rendered = render_lines(&cell.display_lines(80)).join("\n"); insta::assert_snapshot!(rendered, @r###" 🧠 Memory Recall Ready diff --git a/codex-rs/tui_app_server/src/app/app_server_adapter.rs b/codex-rs/tui_app_server/src/app/app_server_adapter.rs index 719efc177..663943659 100644 --- a/codex-rs/tui_app_server/src/app/app_server_adapter.rs +++ b/codex-rs/tui_app_server/src/app/app_server_adapter.rs @@ -79,6 +79,8 @@ use codex_protocol::protocol::ItemCompletedEvent; #[cfg(test)] use codex_protocol::protocol::ItemStartedEvent; #[cfg(test)] +use codex_protocol::protocol::MemoryOperationEvent; +#[cfg(test)] use codex_protocol::protocol::PlanDeltaEvent; #[cfg(test)] use codex_protocol::protocol::RealtimeConversationClosedEvent; @@ -328,6 +330,7 @@ fn server_notification_thread_target( Some(notification.thread_id.as_str()) } ServerNotification::ItemCompleted(notification) => Some(notification.thread_id.as_str()), + ServerNotification::MemoryOperation(notification) => Some(notification.thread_id.as_str()), ServerNotification::RawResponseItemCompleted(notification) => { Some(notification.thread_id.as_str()) } @@ -548,6 +551,43 @@ fn server_notification_thread_events( }), }], )), + ServerNotification::MemoryOperation(notification) => Some(( + ThreadId::from_string(¬ification.thread_id).ok()?, + vec![Event { + id: String::new(), + msg: EventMsg::MemoryOperation(MemoryOperationEvent { + operation: match notification.operation { + codex_app_server_protocol::MemoryOperationKind::Recall => { + codex_protocol::items::MemoryOperationKind::Recall + } + codex_app_server_protocol::MemoryOperationKind::Update => { + codex_protocol::items::MemoryOperationKind::Update + } + codex_app_server_protocol::MemoryOperationKind::Drop => { + codex_protocol::items::MemoryOperationKind::Drop + } + }, + status: match notification.status { + codex_app_server_protocol::MemoryOperationStatus::Pending => { + codex_protocol::items::MemoryOperationStatus::Pending + } + codex_app_server_protocol::MemoryOperationStatus::Ready => { + codex_protocol::items::MemoryOperationStatus::Ready + } + codex_app_server_protocol::MemoryOperationStatus::Empty => { + codex_protocol::items::MemoryOperationStatus::Empty + } + codex_app_server_protocol::MemoryOperationStatus::Error => { + codex_protocol::items::MemoryOperationStatus::Error + } + }, + query: notification.query, + summary: notification.summary, + detail: notification.detail, + context_injected: notification.context_injected, + }), + }], + )), ServerNotification::PlanDelta(notification) => Some(( ThreadId::from_string(¬ification.thread_id).ok()?, vec![Event { diff --git a/codex-rs/tui_app_server/src/chatwidget.rs b/codex-rs/tui_app_server/src/chatwidget.rs index 15d6add0e..8b8a8e2b2 100644 --- a/codex-rs/tui_app_server/src/chatwidget.rs +++ b/codex-rs/tui_app_server/src/chatwidget.rs @@ -71,6 +71,7 @@ use codex_app_server_protocol::ErrorNotification; use codex_app_server_protocol::FileChangeRequestApprovalParams; use codex_app_server_protocol::ItemCompletedNotification; use codex_app_server_protocol::ItemStartedNotification; +use codex_app_server_protocol::MemoryOperationNotification; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::ThreadItem; @@ -173,6 +174,7 @@ use codex_protocol::protocol::McpStartupStatus; use codex_protocol::protocol::McpStartupUpdateEvent; use codex_protocol::protocol::McpToolCallBeginEvent; use codex_protocol::protocol::McpToolCallEndEvent; +use codex_protocol::protocol::MemoryOperationEvent; use codex_protocol::protocol::Op; use codex_protocol::protocol::PatchApplyBeginEvent; use codex_protocol::protocol::RateLimitSnapshot; @@ -2656,11 +2658,7 @@ impl ChatWidget { fn on_error(&mut self, message: String) { self.submit_pending_steers_after_interrupt = false; self.finalize_turn(); - if let Some(cell) = history_cell::memory_error_event(&message) { - self.add_to_history(cell); - } else { - self.add_to_history(history_cell::new_error_event(message)); - } + self.add_to_history(history_cell::new_error_event(message)); self.request_redraw(); // After an error ends the turn, try sending the next queued input. @@ -2692,12 +2690,19 @@ impl ChatWidget { } fn on_warning(&mut self, message: impl Into) { - let message = message.into(); - if let Some(cell) = history_cell::memory_warning_event(&message) { - self.add_to_history(cell); - } else { - self.add_to_history(history_cell::new_warning_event(message)); - } + self.add_to_history(history_cell::new_warning_event(message.into())); + self.request_redraw(); + } + + fn on_memory_operation_notification(&mut self, notification: MemoryOperationNotification) { + self.add_to_history(history_cell::new_memory_operation_notification( + notification, + )); + self.request_redraw(); + } + + fn on_memory_operation_event(&mut self, event: MemoryOperationEvent) { + self.add_to_history(history_cell::new_memory_operation_event(event)); self.request_redraw(); } @@ -6145,6 +6150,9 @@ impl ChatWidget { ServerNotification::ItemCompleted(notification) => { self.handle_item_completed_notification(notification, replay_kind); } + ServerNotification::MemoryOperation(notification) => { + self.on_memory_operation_notification(notification); + } ServerNotification::AgentMessageDelta(notification) => { self.on_agent_message_delta(notification.delta); } @@ -6674,6 +6682,7 @@ impl ChatWidget { self.on_rate_limit_snapshot(ev.rate_limits); } EventMsg::Warning(WarningEvent { message }) => self.on_warning(message), + EventMsg::MemoryOperation(event) => self.on_memory_operation_event(event), EventMsg::GuardianAssessment(ev) => self.on_guardian_assessment(ev), EventMsg::ModelReroute(_) => {} EventMsg::Error(ErrorEvent { diff --git a/codex-rs/tui_app_server/src/chatwidget/tests.rs b/codex-rs/tui_app_server/src/chatwidget/tests.rs index 9ad0172f0..71949bfe6 100644 --- a/codex-rs/tui_app_server/src/chatwidget/tests.rs +++ b/codex-rs/tui_app_server/src/chatwidget/tests.rs @@ -11449,6 +11449,45 @@ async fn app_server_guardian_review_started_sets_review_status() { ); } +#[tokio::test] +async fn app_server_memory_operation_notification_adds_memory_history_cell() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + + chat.handle_server_notification( + ServerNotification::MemoryOperation( + codex_app_server_protocol::MemoryOperationNotification { + thread_id: "thread-1".to_string(), + operation: codex_app_server_protocol::MemoryOperationKind::Recall, + status: codex_app_server_protocol::MemoryOperationStatus::Ready, + query: Some("retrieval freshness".to_string()), + summary: "Recalled memory context and injected it into the current thread." + .to_string(), + detail: Some( + "remember this".to_string(), + ), + context_injected: true, + }, + ), + None, + ); + + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 1, "expected one memory history cell"); + let rendered = lines_to_single_string(&cells[0]); + assert!( + rendered.contains("Memory Recall"), + "missing memory title: {rendered}" + ); + assert!( + rendered.contains("Query: retrieval freshness"), + "missing memory query: {rendered}" + ); + assert!( + rendered.contains("remember this"), + "missing memory detail: {rendered}" + ); +} + #[tokio::test] async fn app_server_guardian_review_denied_renders_denied_request_snapshot() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; diff --git a/codex-rs/tui_app_server/src/history_cell.rs b/codex-rs/tui_app_server/src/history_cell.rs index 325ddaf95..b995fd7b6 100644 --- a/codex-rs/tui_app_server/src/history_cell.rs +++ b/codex-rs/tui_app_server/src/history_cell.rs @@ -40,6 +40,7 @@ use crate::wrapping::adaptive_wrap_line; use crate::wrapping::adaptive_wrap_lines; use base64::Engine; use codex_app_server_protocol::McpServerStatus; +use codex_app_server_protocol::MemoryOperationNotification; use codex_core::config::Config; use codex_core::config::types::McpServerTransportConfig; #[cfg(test)] @@ -63,6 +64,7 @@ use codex_protocol::plan_tool::UpdatePlanArgs; use codex_protocol::protocol::FileChange; use codex_protocol::protocol::McpAuthStatus; use codex_protocol::protocol::McpInvocation; +use codex_protocol::protocol::MemoryOperationEvent; use codex_protocol::protocol::SessionConfiguredEvent; use codex_protocol::request_user_input::RequestUserInputAnswer; use codex_protocol::request_user_input::RequestUserInputQuestion; @@ -2273,18 +2275,6 @@ impl HistoryCell for MemoryHistoryCell { } } -fn parse_memory_query(message: &str) -> Option { - let marker = " for query: "; - let start = message.find(marker)? + marker.len(); - let tail = &message[start..]; - let end = tail - .find(" and ") - .or_else(|| tail.find('.')) - .unwrap_or(tail.len()); - let query = tail[..end].trim(); - (!query.is_empty()).then(|| query.to_string()) -} - pub(crate) fn new_memory_recall_submission(query: Option) -> MemoryHistoryCell { MemoryHistoryCell::new( MemoryOperationKind::Recall, @@ -2325,86 +2315,48 @@ pub(crate) fn new_memory_recall_thread_requirement() -> MemoryHistoryCell { ) } -pub(crate) fn memory_warning_event(message: &str) -> Option { - if let Some((summary, detail)) = message.split_once("\n\n") - && summary.starts_with("Memory context recalled") - { - return Some(MemoryHistoryCell::new( - MemoryOperationKind::Recall, - MemoryOperationState::Success, - parse_memory_query(summary), - "Recalled memory context and injected it into the current thread.".to_string(), - Some(detail.to_string()), - )); - } - - if message.starts_with("No relevant memory context found") { - return Some(MemoryHistoryCell::new( - MemoryOperationKind::Recall, - MemoryOperationState::Empty, - parse_memory_query(message), - "No relevant memory context was found.".to_string(), - /*detail*/ None, - )); - } - - if message.starts_with("Agentmemory sync triggered") - || message.starts_with("Memory update triggered") - { - return Some(MemoryHistoryCell::new( - MemoryOperationKind::Update, - MemoryOperationState::Success, - /*query*/ None, - message.to_string(), - /*detail*/ None, - )); - } - - if message.starts_with("Memory drop completed") { - return Some(MemoryHistoryCell::new( - MemoryOperationKind::Drop, - MemoryOperationState::Success, - /*query*/ None, - message.to_string(), - /*detail*/ None, - )); - } - - None +pub(crate) fn new_memory_operation_event(event: MemoryOperationEvent) -> MemoryHistoryCell { + MemoryHistoryCell::new( + match event.operation { + codex_protocol::items::MemoryOperationKind::Recall => MemoryOperationKind::Recall, + codex_protocol::items::MemoryOperationKind::Update => MemoryOperationKind::Update, + codex_protocol::items::MemoryOperationKind::Drop => MemoryOperationKind::Drop, + }, + match event.status { + codex_protocol::items::MemoryOperationStatus::Pending => MemoryOperationState::Pending, + codex_protocol::items::MemoryOperationStatus::Ready => MemoryOperationState::Success, + codex_protocol::items::MemoryOperationStatus::Empty => MemoryOperationState::Empty, + codex_protocol::items::MemoryOperationStatus::Error => MemoryOperationState::Error, + }, + event.query, + event.summary, + event.detail, + ) } -pub(crate) fn memory_error_event(message: &str) -> Option { - if let Some(detail) = message.strip_prefix("Memory recall failed: ") { - return Some(MemoryHistoryCell::new( - MemoryOperationKind::Recall, - MemoryOperationState::Error, - /*query*/ None, - "Memory recall failed.".to_string(), - Some(detail.to_string()), - )); - } - - if let Some(detail) = message.strip_prefix("Agentmemory sync failed: ") { - return Some(MemoryHistoryCell::new( - MemoryOperationKind::Update, - MemoryOperationState::Error, - /*query*/ None, - "Memory update failed.".to_string(), - Some(detail.to_string()), - )); - } - - if let Some(detail) = message.strip_prefix("Memory drop completed with errors: ") { - return Some(MemoryHistoryCell::new( - MemoryOperationKind::Drop, - MemoryOperationState::Error, - /*query*/ None, - "Memory drop completed with errors.".to_string(), - Some(detail.to_string()), - )); - } - - None +pub(crate) fn new_memory_operation_notification( + notification: MemoryOperationNotification, +) -> MemoryHistoryCell { + MemoryHistoryCell::new( + match notification.operation { + codex_app_server_protocol::MemoryOperationKind::Recall => MemoryOperationKind::Recall, + codex_app_server_protocol::MemoryOperationKind::Update => MemoryOperationKind::Update, + codex_app_server_protocol::MemoryOperationKind::Drop => MemoryOperationKind::Drop, + }, + match notification.status { + codex_app_server_protocol::MemoryOperationStatus::Pending => { + MemoryOperationState::Pending + } + codex_app_server_protocol::MemoryOperationStatus::Ready => { + MemoryOperationState::Success + } + codex_app_server_protocol::MemoryOperationStatus::Empty => MemoryOperationState::Empty, + codex_app_server_protocol::MemoryOperationStatus::Error => MemoryOperationState::Error, + }, + notification.query, + notification.summary, + notification.detail, + ) } /// A transient history cell that shows an animated spinner while the MCP @@ -3378,10 +3330,14 @@ mod tests { #[test] fn memory_recall_result_snapshot() { - let cell = memory_warning_event( - "Memory context recalled for query: retrieval freshness and injected into this thread:\n\nremember this", - ) - .expect("expected memory warning cell"); + let cell = new_memory_operation_event(MemoryOperationEvent { + operation: codex_protocol::items::MemoryOperationKind::Recall, + status: codex_protocol::items::MemoryOperationStatus::Ready, + query: Some("retrieval freshness".to_string()), + summary: "Recalled memory context and injected it into the current thread.".to_string(), + detail: Some("remember this".to_string()), + context_injected: true, + }); let rendered = render_lines(&cell.display_lines(80)).join("\n"); insta::assert_snapshot!(rendered, @r###" 🧠 Memory Recall Ready From f0c1b3ccecc266d3cb892df6fd76e60a279f5e43 Mon Sep 17 00:00:00 2001 From: Eric Juta Date: Mon, 30 Mar 2026 12:36:48 +0100 Subject: [PATCH 39/45] Finish memory UI follow-up lane Make assistant-triggered memory recall visible to humans, collapse memory operations to a single coherent card, add an ambient Agentmemory footer indicator, and update schemas/docs for the new source-aware memory notification. Co-authored-by: Codex --- .../schema/json/ServerNotification.json | 11 + .../codex_app_server_protocol.schemas.json | 11 + .../codex_app_server_protocol.v2.schemas.json | 11 + .../json/v2/MemoryOperationNotification.json | 11 + .../v2/MemoryOperationNotification.ts | 3 +- .../typescript/v2/MemoryOperationSource.ts | 5 + .../schema/typescript/v2/index.ts | 1 + .../app-server-protocol/src/protocol/v2.rs | 19 ++ codex-rs/app-server/README.md | 4 +- .../app-server/src/bespoke_event_handling.rs | 1 + codex-rs/core/src/codex.rs | 198 +++++++++++------- .../core/src/tools/handlers/memory_recall.rs | 60 +++++- codex-rs/docs/agentmemory_followup_spec.md | 11 +- codex-rs/protocol/src/protocol.rs | 9 + codex-rs/tui/src/bottom_pane/chat_composer.rs | 25 ++- codex-rs/tui/src/chatwidget.rs | 59 ++++-- codex-rs/tui/src/chatwidget/tests.rs | 133 ++++++++---- codex-rs/tui/src/history_cell.rs | 37 ++++ .../src/app/app_server_adapter.rs | 8 + .../src/bottom_pane/chat_composer.rs | 25 ++- codex-rs/tui_app_server/src/chatwidget.rs | 112 +++++++++- .../tui_app_server/src/chatwidget/tests.rs | 140 ++++++++----- codex-rs/tui_app_server/src/history_cell.rs | 100 +++++++++ 23 files changed, 792 insertions(+), 202 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/MemoryOperationSource.ts diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index e994a9403..b232f35bf 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -1655,6 +1655,9 @@ "null" ] }, + "source": { + "$ref": "#/definitions/MemoryOperationSource" + }, "status": { "$ref": "#/definitions/MemoryOperationStatus" }, @@ -1668,12 +1671,20 @@ "required": [ "contextInjected", "operation", + "source", "status", "summary", "threadId" ], "type": "object" }, + "MemoryOperationSource": { + "enum": [ + "human", + "assistant" + ], + "type": "string" + }, "MemoryOperationStatus": { "enum": [ "pending", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index c0aa5f122..88776d487 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -9179,6 +9179,9 @@ "null" ] }, + "source": { + "$ref": "#/definitions/v2/MemoryOperationSource" + }, "status": { "$ref": "#/definitions/v2/MemoryOperationStatus" }, @@ -9192,6 +9195,7 @@ "required": [ "contextInjected", "operation", + "source", "status", "summary", "threadId" @@ -9199,6 +9203,13 @@ "title": "MemoryOperationNotification", "type": "object" }, + "MemoryOperationSource": { + "enum": [ + "human", + "assistant" + ], + "type": "string" + }, "MemoryOperationStatus": { "enum": [ "pending", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 7d2ee2492..67506d83b 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -5874,6 +5874,9 @@ "null" ] }, + "source": { + "$ref": "#/definitions/MemoryOperationSource" + }, "status": { "$ref": "#/definitions/MemoryOperationStatus" }, @@ -5887,6 +5890,7 @@ "required": [ "contextInjected", "operation", + "source", "status", "summary", "threadId" @@ -5894,6 +5898,13 @@ "title": "MemoryOperationNotification", "type": "object" }, + "MemoryOperationSource": { + "enum": [ + "human", + "assistant" + ], + "type": "string" + }, "MemoryOperationStatus": { "enum": [ "pending", diff --git a/codex-rs/app-server-protocol/schema/json/v2/MemoryOperationNotification.json b/codex-rs/app-server-protocol/schema/json/v2/MemoryOperationNotification.json index 2ddbbf72c..fa9a8faa0 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/MemoryOperationNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/MemoryOperationNotification.json @@ -9,6 +9,13 @@ ], "type": "string" }, + "MemoryOperationSource": { + "enum": [ + "human", + "assistant" + ], + "type": "string" + }, "MemoryOperationStatus": { "enum": [ "pending", @@ -38,6 +45,9 @@ "null" ] }, + "source": { + "$ref": "#/definitions/MemoryOperationSource" + }, "status": { "$ref": "#/definitions/MemoryOperationStatus" }, @@ -51,6 +61,7 @@ "required": [ "contextInjected", "operation", + "source", "status", "summary", "threadId" diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/MemoryOperationNotification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/MemoryOperationNotification.ts index 41aac16f1..860711b13 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/MemoryOperationNotification.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/MemoryOperationNotification.ts @@ -2,6 +2,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { MemoryOperationKind } from "./MemoryOperationKind"; +import type { MemoryOperationSource } from "./MemoryOperationSource"; import type { MemoryOperationStatus } from "./MemoryOperationStatus"; -export type MemoryOperationNotification = { threadId: string, operation: MemoryOperationKind, status: MemoryOperationStatus, query: string | null, summary: string, detail: string | null, contextInjected: boolean, }; +export type MemoryOperationNotification = { threadId: string, source: MemoryOperationSource, operation: MemoryOperationKind, status: MemoryOperationStatus, query: string | null, summary: string, detail: string | null, contextInjected: boolean, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/MemoryOperationSource.ts b/codex-rs/app-server-protocol/schema/typescript/v2/MemoryOperationSource.ts new file mode 100644 index 000000000..59a91c34c --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/MemoryOperationSource.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type MemoryOperationSource = "human" | "assistant"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index 038ba1092..68f98e89b 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -190,6 +190,7 @@ export type { MemoryCitation } from "./MemoryCitation"; export type { MemoryCitationEntry } from "./MemoryCitationEntry"; export type { MemoryOperationKind } from "./MemoryOperationKind"; export type { MemoryOperationNotification } from "./MemoryOperationNotification"; +export type { MemoryOperationSource } from "./MemoryOperationSource"; export type { MemoryOperationStatus } from "./MemoryOperationStatus"; export type { MergeStrategy } from "./MergeStrategy"; export type { Model } from "./Model"; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index bb7630958..a5cb4c571 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -66,6 +66,7 @@ use codex_protocol::protocol::HookOutputEntryKind as CoreHookOutputEntryKind; use codex_protocol::protocol::HookRunStatus as CoreHookRunStatus; use codex_protocol::protocol::HookRunSummary as CoreHookRunSummary; use codex_protocol::protocol::HookScope as CoreHookScope; +use codex_protocol::protocol::MemoryOperationSource as CoreMemoryOperationSource; use codex_protocol::protocol::ModelRerouteReason as CoreModelRerouteReason; use codex_protocol::protocol::NetworkAccess as CoreNetworkAccess; use codex_protocol::protocol::NonSteerableTurnKind as CoreNonSteerableTurnKind; @@ -3053,6 +3054,23 @@ impl From for MemoryOperationStatus { } } +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(rename_all = "snake_case", export_to = "v2/")] +pub enum MemoryOperationSource { + Human, + Assistant, +} + +impl From for MemoryOperationSource { + fn from(value: CoreMemoryOperationSource) -> Self { + match value { + CoreMemoryOperationSource::Human => Self::Human, + CoreMemoryOperationSource::Assistant => Self::Assistant, + } + } +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -5065,6 +5083,7 @@ pub struct ItemCompletedNotification { #[ts(export_to = "v2/")] pub struct MemoryOperationNotification { pub thread_id: String, + pub source: MemoryOperationSource, pub operation: MemoryOperationKind, pub status: MemoryOperationStatus, pub query: Option, diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index ac1de9aef..0400b469b 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -465,7 +465,7 @@ Use the thread-scoped memory methods to mirror the legacy TUI slash commands: - `thread/memory/update` triggers a backend-specific sync/consolidation pass. - `thread/memory/recall` retrieves memory context and injects it into the thread as developer instructions. -All three requests return immediately with `{}`. Result details surface through the thread event stream as `thread/memory/operation` notifications carrying the structured operation, status, optional query, summary, optional detail, and whether recalled context was injected into the thread. +All three requests return immediately with `{}`. Result details surface through the thread event stream as `thread/memory/operation` notifications carrying the structured source, operation, status, optional query, summary, optional detail, and whether recalled context was injected into the thread. ```json { "method": "thread/memory/drop", "id": 27, "params": { "threadId": "thr_b" } } @@ -942,7 +942,7 @@ All items emit shared lifecycle events: - `item/completed` — sends the final `item` once that work itself finishes (for example, after a tool call or message completes); treat this as the authoritative execution/result state. - `item/autoApprovalReview/started` — [UNSTABLE] temporary guardian notification carrying `{threadId, turnId, targetItemId, review, action?}` when guardian approval review begins. This shape is expected to change soon. - `item/autoApprovalReview/completed` — [UNSTABLE] temporary guardian notification carrying `{threadId, turnId, targetItemId, review, action?}` when guardian approval review resolves. This shape is expected to change soon. -- `thread/memory/operation` — sends structured outcomes for `thread/memory/drop`, `thread/memory/update`, and `thread/memory/recall` with `{threadId, operation, status, query?, summary, detail?, contextInjected}`. +- `thread/memory/operation` — sends structured outcomes for `thread/memory/drop`, `thread/memory/update`, and `thread/memory/recall` with `{threadId, source, operation, status, query?, summary, detail?, contextInjected}`. `review` is [UNSTABLE] and currently has `{status, riskScore?, riskLevel?, rationale?}`, where `status` is one of `inProgress`, `approved`, `denied`, or `aborted`. `action` is the guardian action summary payload from core when available and is intended to support temporary standalone pending-review UI. These notifications are separate from the target item's own `item/completed` lifecycle and are intentionally temporary while the guardian app protocol is still being designed. diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 189f6fc73..246aaad33 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -347,6 +347,7 @@ pub(crate) async fn apply_bespoke_event_handling( if let ApiVersion::V2 = api_version { let notification = MemoryOperationNotification { thread_id: conversation_id.to_string(), + source: event.source.into(), operation: event.operation.into(), status: event.status.into(), query: event.query, diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 5c0026852..3ea4ed39a 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -4612,6 +4612,7 @@ mod handlers { use codex_protocol::protocol::ListSkillsResponseEvent; use codex_protocol::protocol::McpServerRefreshConfig; use codex_protocol::protocol::MemoryOperationEvent; + use codex_protocol::protocol::MemoryOperationSource; use codex_protocol::protocol::Op; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewRequest; @@ -5123,19 +5124,34 @@ mod handlers { .await; } - async fn send_memory_operation_event( - sess: &Session, - sub_id: &str, + struct MemoryOperationEventArgs { + source: MemoryOperationSource, operation: MemoryOperationKind, status: MemoryOperationStatus, query: Option, summary: String, detail: Option, context_injected: bool, + } + + async fn send_memory_operation_event( + sess: &Session, + sub_id: &str, + args: MemoryOperationEventArgs, ) { + let MemoryOperationEventArgs { + source, + operation, + status, + query, + summary, + detail, + context_injected, + } = args; sess.send_event_raw(Event { id: sub_id.to_string(), msg: EventMsg::MemoryOperation(MemoryOperationEvent { + source, operation, status, query, @@ -5154,12 +5170,15 @@ mod handlers { send_memory_operation_event( sess, &sub_id, - MemoryOperationKind::Drop, - MemoryOperationStatus::Error, - /*query*/ None, - "Memory drop failed.".to_string(), - Some(e.to_string()), - /*context_injected*/ false, + MemoryOperationEventArgs { + source: MemoryOperationSource::Human, + operation: MemoryOperationKind::Drop, + status: MemoryOperationStatus::Error, + query: None, + summary: "Memory drop failed.".to_string(), + detail: Some(e.to_string()), + context_injected: false, + }, ) .await; return; @@ -5167,12 +5186,15 @@ mod handlers { send_memory_operation_event( sess, &sub_id, - MemoryOperationKind::Drop, - MemoryOperationStatus::Ready, - /*query*/ None, - "Cleared Agentmemory contents.".to_string(), - /*detail*/ None, - /*context_injected*/ false, + MemoryOperationEventArgs { + source: MemoryOperationSource::Human, + operation: MemoryOperationKind::Drop, + status: MemoryOperationStatus::Ready, + query: None, + summary: "Cleared Agentmemory contents.".to_string(), + detail: None, + context_injected: false, + }, ) .await; return; @@ -5200,15 +5222,18 @@ mod handlers { send_memory_operation_event( sess, &sub_id, - MemoryOperationKind::Drop, - MemoryOperationStatus::Ready, - /*query*/ None, - "Dropped stored memories for this workspace.".to_string(), - Some(format!( - "Cleared memory rows from the state db and removed stored memory files at {}.", - memory_root.display() - )), - /*context_injected*/ false, + MemoryOperationEventArgs { + source: MemoryOperationSource::Human, + operation: MemoryOperationKind::Drop, + status: MemoryOperationStatus::Ready, + query: None, + summary: "Dropped stored memories for this workspace.".to_string(), + detail: Some(format!( + "Cleared memory rows from the state db and removed stored memory files at {}.", + memory_root.display() + )), + context_injected: false, + }, ) .await; return; @@ -5217,12 +5242,15 @@ mod handlers { send_memory_operation_event( sess, &sub_id, - MemoryOperationKind::Drop, - MemoryOperationStatus::Error, - /*query*/ None, - "Memory drop completed with errors.".to_string(), - Some(errors.join("; ")), - /*context_injected*/ false, + MemoryOperationEventArgs { + source: MemoryOperationSource::Human, + operation: MemoryOperationKind::Drop, + status: MemoryOperationStatus::Error, + query: None, + summary: "Memory drop completed with errors.".to_string(), + detail: Some(errors.join("; ")), + context_injected: false, + }, ) .await; } @@ -5239,12 +5267,15 @@ mod handlers { send_memory_operation_event( sess, &sub_id, - MemoryOperationKind::Update, - MemoryOperationStatus::Error, - /*query*/ None, - "Memory update failed.".to_string(), - Some(e.to_string()), - /*context_injected*/ false, + MemoryOperationEventArgs { + source: MemoryOperationSource::Human, + operation: MemoryOperationKind::Update, + status: MemoryOperationStatus::Error, + query: None, + summary: "Memory update failed.".to_string(), + detail: Some(e.to_string()), + context_injected: false, + }, ) .await; return; @@ -5252,14 +5283,17 @@ mod handlers { send_memory_operation_event( sess, &sub_id, - MemoryOperationKind::Update, - MemoryOperationStatus::Ready, - /*query*/ None, - "Agentmemory sync triggered.".to_string(), - Some( - "Updated observations will appear in future memory recalls once consolidation completes.".to_string(), - ), - /*context_injected*/ false, + MemoryOperationEventArgs { + source: MemoryOperationSource::Human, + operation: MemoryOperationKind::Update, + status: MemoryOperationStatus::Ready, + query: None, + summary: "Agentmemory sync triggered.".to_string(), + detail: Some( + "Updated observations will appear in future memory recalls once consolidation completes.".to_string(), + ), + context_injected: false, + }, ) .await; return; @@ -5272,12 +5306,15 @@ mod handlers { send_memory_operation_event( sess, &sub_id, - MemoryOperationKind::Update, - MemoryOperationStatus::Ready, - /*query*/ None, - "Memory update triggered.".to_string(), - Some("Consolidation is running in the background.".to_string()), - /*context_injected*/ false, + MemoryOperationEventArgs { + source: MemoryOperationSource::Human, + operation: MemoryOperationKind::Update, + status: MemoryOperationStatus::Ready, + query: None, + summary: "Memory update triggered.".to_string(), + detail: Some("Consolidation is running in the background.".to_string()), + context_injected: false, + }, ) .await; } @@ -5292,12 +5329,15 @@ mod handlers { send_memory_operation_event( sess, &sub_id, - MemoryOperationKind::Recall, - MemoryOperationStatus::Error, - query, - "Memory recall requires agentmemory backend.".to_string(), - /*detail*/ None, - /*context_injected*/ false, + MemoryOperationEventArgs { + source: MemoryOperationSource::Human, + operation: MemoryOperationKind::Recall, + status: MemoryOperationStatus::Error, + query, + summary: "Memory recall requires agentmemory backend.".to_string(), + detail: None, + context_injected: false, + }, ) .await; return; @@ -5322,12 +5362,16 @@ mod handlers { send_memory_operation_event( sess, &sub_id, - MemoryOperationKind::Recall, - MemoryOperationStatus::Ready, - query, - "Recalled memory context and injected it into the current thread.".to_string(), - Some(context), - /*context_injected*/ true, + MemoryOperationEventArgs { + source: MemoryOperationSource::Human, + operation: MemoryOperationKind::Recall, + status: MemoryOperationStatus::Ready, + query, + summary: "Recalled memory context and injected it into the current thread." + .to_string(), + detail: Some(context), + context_injected: true, + }, ) .await; } @@ -5335,12 +5379,15 @@ mod handlers { send_memory_operation_event( sess, &sub_id, - MemoryOperationKind::Recall, - MemoryOperationStatus::Empty, - query, - "No relevant memory context was found.".to_string(), - /*detail*/ None, - /*context_injected*/ false, + MemoryOperationEventArgs { + source: MemoryOperationSource::Human, + operation: MemoryOperationKind::Recall, + status: MemoryOperationStatus::Empty, + query, + summary: "No relevant memory context was found.".to_string(), + detail: None, + context_injected: false, + }, ) .await; } @@ -5348,12 +5395,15 @@ mod handlers { send_memory_operation_event( sess, &sub_id, - MemoryOperationKind::Recall, - MemoryOperationStatus::Error, - query, - "Memory recall failed.".to_string(), - Some(e.to_string()), - /*context_injected*/ false, + MemoryOperationEventArgs { + source: MemoryOperationSource::Human, + operation: MemoryOperationKind::Recall, + status: MemoryOperationStatus::Error, + query, + summary: "Memory recall failed.".to_string(), + detail: Some(e.to_string()), + context_injected: false, + }, ) .await; } diff --git a/codex-rs/core/src/tools/handlers/memory_recall.rs b/codex-rs/core/src/tools/handlers/memory_recall.rs index 9327b236d..b07afe3ca 100644 --- a/codex-rs/core/src/tools/handlers/memory_recall.rs +++ b/codex-rs/core/src/tools/handlers/memory_recall.rs @@ -1,4 +1,7 @@ use async_trait::async_trait; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::MemoryOperationEvent; +use codex_protocol::protocol::MemoryOperationSource; use serde::Deserialize; use crate::config::types::MemoryBackend; @@ -9,6 +12,8 @@ use crate::tools::context::ToolPayload; use crate::tools::handlers::parse_arguments; use crate::tools::registry::ToolHandler; use crate::tools::registry::ToolKind; +use codex_protocol::items::MemoryOperationKind; +use codex_protocol::items::MemoryOperationStatus; #[derive(Debug, Deserialize)] struct MemoryRecallArgs { @@ -57,16 +62,63 @@ impl ToolHandler for MemoryRecallHandler { .filter(|query| !query.is_empty()); let adapter = crate::agentmemory::AgentmemoryAdapter::new(); - let response = adapter + let response = match adapter .recall_for_runtime( &session.conversation_id.to_string(), turn.cwd.as_path(), query, ) .await - .map_err(|err| { - FunctionCallError::RespondToModel(format!("memory_recall failed: {err}")) - })?; + { + Ok(response) => response, + Err(err) => { + session + .send_event( + turn.as_ref(), + EventMsg::MemoryOperation(MemoryOperationEvent { + source: MemoryOperationSource::Assistant, + operation: MemoryOperationKind::Recall, + status: MemoryOperationStatus::Error, + query: args.query.clone(), + summary: "Assistant memory recall failed.".to_string(), + detail: Some(err.to_string()), + context_injected: false, + }), + ) + .await; + return Err(FunctionCallError::RespondToModel(format!( + "memory_recall failed: {err}" + ))); + } + }; + + session + .send_event( + turn.as_ref(), + EventMsg::MemoryOperation(if response.recalled { + MemoryOperationEvent { + source: MemoryOperationSource::Assistant, + operation: MemoryOperationKind::Recall, + status: MemoryOperationStatus::Ready, + query: args.query.clone(), + summary: "Assistant recalled memory context for this turn.".to_string(), + detail: Some(response.context.clone()), + context_injected: false, + } + } else { + MemoryOperationEvent { + source: MemoryOperationSource::Assistant, + operation: MemoryOperationKind::Recall, + status: MemoryOperationStatus::Empty, + query: args.query.clone(), + summary: "Assistant found no relevant memory context for this turn." + .to_string(), + detail: None, + context_injected: false, + } + }), + ) + .await; let content = serde_json::to_string(&response).map_err(|err| { FunctionCallError::Fatal(format!("failed to serialize memory_recall response: {err}")) diff --git a/codex-rs/docs/agentmemory_followup_spec.md b/codex-rs/docs/agentmemory_followup_spec.md index 189da439c..5d84619ee 100644 --- a/codex-rs/docs/agentmemory_followup_spec.md +++ b/codex-rs/docs/agentmemory_followup_spec.md @@ -8,8 +8,12 @@ visual-memory-UI lanes. Priority 1 is now implemented for the human-triggered memory control plane: structured memory events replace string-matched warning/error parsing for `/memory-recall`, `/memory-update`, and `/memory-drop` across both TUIs. -The remaining backlog starts at in-place completion updates and -assistant-triggered memory visibility. +Priority 2 and Priority 3 are also now implemented: +`/memory-*` operations collapse to a single coherent card per operation, and +assistant-triggered `memory_recall` is visible to the human transcript. +Priority 5 is also implemented with a lightweight ambient availability +indicator in the TUI footer. The remaining backlog starts at richer metadata +and broader end-to-end confidence work. This document exists to answer one practical question: @@ -27,6 +31,9 @@ The fork already has: - proactive runtime guidance for when the assistant should use recall - dedicated visual memory history cells in both TUIs - structured memory outcome events for human-triggered recall/update/drop +- a single coherent memory card per human-triggered operation +- visible assistant-triggered `memory_recall` events in both TUIs +- a lightweight ambient memory-availability indicator in the footer That means the system is functionally good. What remains is mostly structural cleanup, richer human visibility, and better retrieval/capture quality. diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 567b72544..11296ee56 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1847,9 +1847,18 @@ pub struct WarningEvent { pub message: String, } +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(rename_all = "snake_case")] +pub enum MemoryOperationSource { + Human, + Assistant, +} + #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] #[serde(rename_all = "snake_case")] pub struct MemoryOperationEvent { + pub source: MemoryOperationSource, pub operation: MemoryOperationKind, pub status: MemoryOperationStatus, pub query: Option, diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index cfdf2ac5e..33757c12d 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -3203,6 +3203,16 @@ impl ChatComposer { } }; + let mut status_line_value = self.status_line_value.clone(); + if self.agentmemory_enabled { + if let Some(existing) = status_line_value.as_mut() { + existing.spans.push(" · ".into()); + existing.spans.push("Agentmemory".dim()); + } else { + status_line_value = Some(Line::from("Agentmemory".dim())); + } + } + FooterProps { mode, esc_backtrack_hint: self.esc_backtrack_hint, @@ -3213,7 +3223,7 @@ impl ChatComposer { is_wsl, context_window_percent: self.context_window_percent, context_window_used_tokens: self.context_window_used_tokens, - status_line_value: self.status_line_value.clone(), + status_line_value, status_line_enabled: self.status_line_enabled, active_agent_label: self.active_agent_label.clone(), } @@ -4761,6 +4771,19 @@ mod tests { }); } + #[test] + fn footer_status_line_shows_agentmemory_indicator() { + snapshot_composer_state( + "footer_status_line_with_agentmemory_indicator", + true, + |composer| { + composer.set_status_line_enabled(true); + composer.set_status_line(Some(Line::from("Status line content".to_string()))); + composer.set_agentmemory_enabled(true); + }, + ); + } + #[test] fn footer_collapse_snapshots() { fn setup_collab_footer( diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 5928f7d5a..2d731064a 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -136,6 +136,7 @@ use codex_protocol::protocol::McpStartupUpdateEvent; use codex_protocol::protocol::McpToolCallBeginEvent; use codex_protocol::protocol::McpToolCallEndEvent; use codex_protocol::protocol::MemoryOperationEvent; +use codex_protocol::protocol::MemoryOperationSource; use codex_protocol::protocol::Op; use codex_protocol::protocol::PatchApplyBeginEvent; use codex_protocol::protocol::RateLimitSnapshot; @@ -2219,10 +2220,43 @@ impl ChatWidget { } fn on_memory_operation(&mut self, event: MemoryOperationEvent) { + if self.try_complete_pending_memory_operation(&event) { + self.request_redraw(); + return; + } self.add_to_history(history_cell::new_memory_operation_event(event)); self.request_redraw(); } + fn show_pending_memory_operation(&mut self, cell: history_cell::MemoryHistoryCell) { + self.flush_active_cell(); + self.active_cell = Some(Box::new(cell)); + self.bump_active_cell_revision(); + self.request_redraw(); + } + + fn try_complete_pending_memory_operation(&mut self, event: &MemoryOperationEvent) -> bool { + if event.source != MemoryOperationSource::Human { + return false; + } + let Some(active) = self.active_cell.as_mut() else { + return false; + }; + let Some(memory) = active + .as_any_mut() + .downcast_mut::() + else { + return false; + }; + if !memory.is_human_pending_submission(event.operation, event.query.as_deref()) { + return false; + } + memory.apply_event(event.clone()); + self.bump_active_cell_revision(); + self.flush_active_cell(); + true + } + fn ensure_memory_recall_thread(&mut self) -> bool { if self.thread_id.is_some() { return true; @@ -3851,7 +3885,8 @@ impl ChatWidget { widget.config.features.enabled(Feature::VoiceTranscription), ); widget.bottom_pane.set_agentmemory_enabled( - widget.config.memories.backend == codex_core::config::types::MemoryBackend::Agentmemory, + widget.config.memories.backend == codex_core::config::types::MemoryBackend::Agentmemory + && widget.config.features.enabled(Feature::MemoryTool), ); widget .bottom_pane @@ -4058,7 +4093,8 @@ impl ChatWidget { widget.config.features.enabled(Feature::VoiceTranscription), ); widget.bottom_pane.set_agentmemory_enabled( - widget.config.memories.backend == codex_core::config::types::MemoryBackend::Agentmemory, + widget.config.memories.backend == codex_core::config::types::MemoryBackend::Agentmemory + && widget.config.features.enabled(Feature::MemoryTool), ); widget .bottom_pane @@ -4257,7 +4293,8 @@ impl ChatWidget { widget.config.features.enabled(Feature::VoiceTranscription), ); widget.bottom_pane.set_agentmemory_enabled( - widget.config.memories.backend == codex_core::config::types::MemoryBackend::Agentmemory, + widget.config.memories.backend == codex_core::config::types::MemoryBackend::Agentmemory + && widget.config.features.enabled(Feature::MemoryTool), ); widget .bottom_pane @@ -4815,23 +4852,20 @@ impl ChatWidget { self.clean_background_terminals(); } SlashCommand::MemoryDrop => { - self.add_to_history(history_cell::new_memory_drop_submission()); - self.request_redraw(); + self.show_pending_memory_operation(history_cell::new_memory_drop_submission()); self.submit_op(Op::DropMemories); } SlashCommand::MemoryUpdate => { - self.add_to_history(history_cell::new_memory_update_submission()); - self.request_redraw(); + self.show_pending_memory_operation(history_cell::new_memory_update_submission()); self.submit_op(Op::UpdateMemories); } SlashCommand::MemoryRecall => { if !self.ensure_memory_recall_thread() { return; } - self.add_to_history(history_cell::new_memory_recall_submission( + self.show_pending_memory_operation(history_cell::new_memory_recall_submission( /*query*/ None, )); - self.request_redraw(); self.submit_op(Op::RecallMemories { query: None }); } SlashCommand::Mcp => { @@ -5029,10 +5063,9 @@ impl ChatWidget { if !self.ensure_memory_recall_thread() { return; } - self.add_to_history(history_cell::new_memory_recall_submission(Some( - trimmed.to_string(), - ))); - self.request_redraw(); + self.show_pending_memory_operation(history_cell::new_memory_recall_submission( + Some(trimmed.to_string()), + )); self.submit_op(Op::RecallMemories { query: Some(trimmed.to_string()), }); diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 403f01539..c8a713ad8 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -6517,17 +6517,15 @@ async fn slash_memory_drop_submits_drop_memories_op() { chat.dispatch_command(SlashCommand::MemoryDrop); - let event = rx.try_recv().expect("expected memory drop info"); - match event { - AppEvent::InsertHistoryCell(cell) => { - let rendered = lines_to_single_string(&cell.display_lines(80)); - assert!( - rendered.contains("Memory Drop"), - "expected memory drop info, got {rendered:?}" - ); - } - other => panic!("expected InsertHistoryCell info, got {other:?}"), - } + assert!( + active_blob(&chat).contains("Memory Drop"), + "expected active memory drop card, got {:?}", + active_blob(&chat) + ); + assert!( + rx.try_recv().is_err(), + "expected no committed history cell yet" + ); assert_matches!(op_rx.try_recv(), Ok(Op::DropMemories)); } @@ -6537,17 +6535,15 @@ async fn slash_memory_update_submits_update_memories_op() { chat.dispatch_command(SlashCommand::MemoryUpdate); - let event = rx.try_recv().expect("expected memory update info"); - match event { - AppEvent::InsertHistoryCell(cell) => { - let rendered = lines_to_single_string(&cell.display_lines(80)); - assert!( - rendered.contains("Memory Update"), - "expected memory update info, got {rendered:?}" - ); - } - other => panic!("expected InsertHistoryCell info, got {other:?}"), - } + assert!( + active_blob(&chat).contains("Memory Update"), + "expected active memory update card, got {:?}", + active_blob(&chat) + ); + assert!( + rx.try_recv().is_err(), + "expected no committed history cell yet" + ); assert_matches!(op_rx.try_recv(), Ok(Op::UpdateMemories)); } @@ -6610,17 +6606,15 @@ async fn slash_memory_recall_submits_recall_memories_op() { chat.dispatch_command(SlashCommand::MemoryRecall); - let event = rx.try_recv().expect("expected memory recall info"); - match event { - AppEvent::InsertHistoryCell(cell) => { - let rendered = lines_to_single_string(&cell.display_lines(80)); - assert!( - rendered.contains("Memory Recall"), - "expected memory recall info, got {rendered:?}" - ); - } - other => panic!("expected InsertHistoryCell info, got {other:?}"), - } + assert!( + active_blob(&chat).contains("Memory Recall"), + "expected active memory recall card, got {:?}", + active_blob(&chat) + ); + assert!( + rx.try_recv().is_err(), + "expected no committed history cell yet" + ); assert_matches!(op_rx.try_recv(), Ok(Op::RecallMemories { query: None })); } @@ -6637,17 +6631,15 @@ async fn slash_memory_recall_with_inline_args_submits_query() { ); chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); - let event = rx.try_recv().expect("expected memory recall info"); - match event { - AppEvent::InsertHistoryCell(cell) => { - let rendered = lines_to_single_string(&cell.display_lines(80)); - assert!( - rendered.contains("Query: retrieval freshness"), - "expected memory recall info, got {rendered:?}" - ); - } - other => panic!("expected InsertHistoryCell info, got {other:?}"), - } + assert!( + active_blob(&chat).contains("Query: retrieval freshness"), + "expected active memory recall card, got {:?}", + active_blob(&chat) + ); + assert!( + rx.try_recv().is_err(), + "expected no committed history cell yet" + ); assert_matches!( op_rx.try_recv(), Ok(Op::RecallMemories { @@ -11611,12 +11603,13 @@ async fn memory_operation_event_adds_memory_history_cell() { chat.handle_codex_event(Event { id: "sub-1".into(), msg: EventMsg::MemoryOperation(codex_protocol::protocol::MemoryOperationEvent { + source: codex_protocol::protocol::MemoryOperationSource::Assistant, operation: codex_protocol::items::MemoryOperationKind::Recall, status: codex_protocol::items::MemoryOperationStatus::Ready, query: Some("retrieval freshness".to_string()), - summary: "Recalled memory context and injected it into the current thread.".to_string(), + summary: "Assistant recalled memory context for this turn.".to_string(), detail: Some("remember this".to_string()), - context_injected: true, + context_injected: false, }), }); @@ -11631,12 +11624,60 @@ async fn memory_operation_event_adds_memory_history_cell() { rendered.contains("Query: retrieval freshness"), "missing memory query: {rendered}" ); + assert!( + rendered.contains("Source: assistant tool"), + "missing assistant source label: {rendered}" + ); assert!( rendered.contains("remember this"), "missing memory detail: {rendered}" ); } +#[tokio::test] +async fn human_memory_recall_completion_replaces_pending_card_in_place() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; + chat.bottom_pane.set_agentmemory_enabled(true); + chat.thread_id = Some(ThreadId::new()); + + chat.bottom_pane.set_composer_text( + "/memory-recall retrieval freshness".to_string(), + Vec::new(), + Vec::new(), + ); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + assert_matches!( + op_rx.try_recv(), + Ok(Op::RecallMemories { + query: Some(query) + }) if query == "retrieval freshness" + ); + assert!( + rx.try_recv().is_err(), + "expected pending card to stay active" + ); + + chat.handle_codex_event(Event { + id: "sub-1".into(), + msg: EventMsg::MemoryOperation(codex_protocol::protocol::MemoryOperationEvent { + source: codex_protocol::protocol::MemoryOperationSource::Human, + operation: codex_protocol::items::MemoryOperationKind::Recall, + status: codex_protocol::items::MemoryOperationStatus::Ready, + query: Some("retrieval freshness".to_string()), + summary: "Recalled memory context and injected it into the current thread.".to_string(), + detail: Some("remember this".to_string()), + context_injected: true, + }), + }); + + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 1, "expected a single completed memory card"); + let rendered = lines_to_single_string(&cells[0]); + assert!(rendered.contains("Memory Recall Ready")); + assert!(rendered.contains("Query: retrieval freshness")); + assert!(rx.try_recv().is_err(), "expected no extra history insert"); +} + #[tokio::test] async fn status_line_invalid_items_warn_once() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index a0ec337de..0b53c5cf0 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -61,6 +61,7 @@ use codex_protocol::protocol::FileChange; use codex_protocol::protocol::McpAuthStatus; use codex_protocol::protocol::McpInvocation; use codex_protocol::protocol::MemoryOperationEvent; +use codex_protocol::protocol::MemoryOperationSource; use codex_protocol::protocol::SessionConfiguredEvent; use codex_protocol::request_user_input::RequestUserInputAnswer; use codex_protocol::request_user_input::RequestUserInputQuestion; @@ -1990,6 +1991,7 @@ const MEMORY_PREVIEW_MAX_GRAPHEMES: usize = 1_200; #[derive(Debug)] pub(crate) struct MemoryHistoryCell { + source: MemoryOperationSource, operation: MemoryOperationKind, status: MemoryOperationStatus, query: Option, @@ -1999,6 +2001,7 @@ pub(crate) struct MemoryHistoryCell { impl MemoryHistoryCell { fn new( + source: MemoryOperationSource, operation: MemoryOperationKind, status: MemoryOperationStatus, query: Option, @@ -2006,6 +2009,7 @@ impl MemoryHistoryCell { detail: Option, ) -> Self { Self { + source, operation, status, query, @@ -2038,6 +2042,29 @@ impl MemoryHistoryCell { .as_deref() .map(|detail| truncate_text(detail, MEMORY_PREVIEW_MAX_GRAPHEMES)) } + + pub(crate) fn is_human_pending_submission( + &self, + operation: MemoryOperationKind, + query: Option<&str>, + ) -> bool { + self.source == MemoryOperationSource::Human + && self.status == MemoryOperationStatus::Pending + && self.operation == operation + && self.query.as_deref().map(str::trim) == query.map(str::trim) + } + + pub(crate) fn apply_event(&mut self, event: MemoryOperationEvent) { + self.source = event.source; + self.operation = event.operation; + self.status = event.status; + self.query = event.query; + self.summary = event.summary; + self.detail = event + .detail + .map(|detail| detail.trim().to_string()) + .filter(|detail| !detail.is_empty()); + } } impl HistoryCell for MemoryHistoryCell { @@ -2059,6 +2086,10 @@ impl HistoryCell for MemoryHistoryCell { push_owned_lines(&wrapped, &mut lines); } + if self.source == MemoryOperationSource::Assistant { + lines.push(Line::from(vec![" Source: ".dim(), "assistant tool".dim()])); + } + let summary_line = Line::from(vec![" ".into(), self.summary.clone().into()]); let wrapped_summary = adaptive_wrap_line(&summary_line, RtOptions::new(wrap_width)); push_owned_lines(&wrapped_summary, &mut lines); @@ -2081,6 +2112,7 @@ impl HistoryCell for MemoryHistoryCell { pub(crate) fn new_memory_recall_submission(query: Option) -> MemoryHistoryCell { MemoryHistoryCell::new( + MemoryOperationSource::Human, MemoryOperationKind::Recall, MemoryOperationStatus::Pending, query, @@ -2091,6 +2123,7 @@ pub(crate) fn new_memory_recall_submission(query: Option) -> MemoryHisto pub(crate) fn new_memory_update_submission() -> MemoryHistoryCell { MemoryHistoryCell::new( + MemoryOperationSource::Human, MemoryOperationKind::Update, MemoryOperationStatus::Pending, /*query*/ None, @@ -2101,6 +2134,7 @@ pub(crate) fn new_memory_update_submission() -> MemoryHistoryCell { pub(crate) fn new_memory_drop_submission() -> MemoryHistoryCell { MemoryHistoryCell::new( + MemoryOperationSource::Human, MemoryOperationKind::Drop, MemoryOperationStatus::Pending, /*query*/ None, @@ -2111,6 +2145,7 @@ pub(crate) fn new_memory_drop_submission() -> MemoryHistoryCell { pub(crate) fn new_memory_recall_thread_requirement() -> MemoryHistoryCell { MemoryHistoryCell::new( + MemoryOperationSource::Human, MemoryOperationKind::Recall, MemoryOperationStatus::Error, /*query*/ None, @@ -2121,6 +2156,7 @@ pub(crate) fn new_memory_recall_thread_requirement() -> MemoryHistoryCell { pub(crate) fn new_memory_operation_event(event: MemoryOperationEvent) -> MemoryHistoryCell { MemoryHistoryCell::new( + event.source, event.operation, event.status, event.query, @@ -3052,6 +3088,7 @@ mod tests { #[test] fn memory_recall_result_snapshot() { let cell = new_memory_operation_event(MemoryOperationEvent { + source: MemoryOperationSource::Human, operation: MemoryOperationKind::Recall, status: MemoryOperationStatus::Ready, query: Some("retrieval freshness".to_string()), diff --git a/codex-rs/tui_app_server/src/app/app_server_adapter.rs b/codex-rs/tui_app_server/src/app/app_server_adapter.rs index 663943659..dd647eb74 100644 --- a/codex-rs/tui_app_server/src/app/app_server_adapter.rs +++ b/codex-rs/tui_app_server/src/app/app_server_adapter.rs @@ -556,6 +556,14 @@ fn server_notification_thread_events( vec![Event { id: String::new(), msg: EventMsg::MemoryOperation(MemoryOperationEvent { + source: match notification.source { + codex_app_server_protocol::MemoryOperationSource::Human => { + codex_protocol::protocol::MemoryOperationSource::Human + } + codex_app_server_protocol::MemoryOperationSource::Assistant => { + codex_protocol::protocol::MemoryOperationSource::Assistant + } + }, operation: match notification.operation { codex_app_server_protocol::MemoryOperationKind::Recall => { codex_protocol::items::MemoryOperationKind::Recall diff --git a/codex-rs/tui_app_server/src/bottom_pane/chat_composer.rs b/codex-rs/tui_app_server/src/bottom_pane/chat_composer.rs index b8273b470..e797481eb 100644 --- a/codex-rs/tui_app_server/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui_app_server/src/bottom_pane/chat_composer.rs @@ -3217,6 +3217,16 @@ impl ChatComposer { } }; + let mut status_line_value = self.status_line_value.clone(); + if self.agentmemory_enabled { + if let Some(existing) = status_line_value.as_mut() { + existing.spans.push(" · ".into()); + existing.spans.push("Agentmemory".dim()); + } else { + status_line_value = Some(Line::from("Agentmemory".dim())); + } + } + FooterProps { mode, esc_backtrack_hint: self.esc_backtrack_hint, @@ -3227,7 +3237,7 @@ impl ChatComposer { is_wsl, context_window_percent: self.context_window_percent, context_window_used_tokens: self.context_window_used_tokens, - status_line_value: self.status_line_value.clone(), + status_line_value, status_line_enabled: self.status_line_enabled, active_agent_label: self.active_agent_label.clone(), } @@ -4777,6 +4787,19 @@ mod tests { }); } + #[test] + fn footer_status_line_shows_agentmemory_indicator() { + snapshot_composer_state( + "footer_status_line_with_agentmemory_indicator", + true, + |composer| { + composer.set_status_line_enabled(true); + composer.set_status_line(Some(Line::from("Status line content".to_string()))); + composer.set_agentmemory_enabled(true); + }, + ); + } + #[test] fn footer_collapse_snapshots() { fn setup_collab_footer( diff --git a/codex-rs/tui_app_server/src/chatwidget.rs b/codex-rs/tui_app_server/src/chatwidget.rs index 8b8a8e2b2..8ad50c372 100644 --- a/codex-rs/tui_app_server/src/chatwidget.rs +++ b/codex-rs/tui_app_server/src/chatwidget.rs @@ -72,6 +72,7 @@ use codex_app_server_protocol::FileChangeRequestApprovalParams; use codex_app_server_protocol::ItemCompletedNotification; use codex_app_server_protocol::ItemStartedNotification; use codex_app_server_protocol::MemoryOperationNotification; +use codex_app_server_protocol::MemoryOperationSource as AppServerMemoryOperationSource; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::ThreadItem; @@ -174,7 +175,10 @@ use codex_protocol::protocol::McpStartupStatus; use codex_protocol::protocol::McpStartupUpdateEvent; use codex_protocol::protocol::McpToolCallBeginEvent; use codex_protocol::protocol::McpToolCallEndEvent; +#[cfg(test)] use codex_protocol::protocol::MemoryOperationEvent; +#[cfg(test)] +use codex_protocol::protocol::MemoryOperationSource; use codex_protocol::protocol::Op; use codex_protocol::protocol::PatchApplyBeginEvent; use codex_protocol::protocol::RateLimitSnapshot; @@ -2695,17 +2699,106 @@ impl ChatWidget { } fn on_memory_operation_notification(&mut self, notification: MemoryOperationNotification) { + if self.try_complete_pending_memory_operation_notification(¬ification) { + self.request_redraw(); + return; + } self.add_to_history(history_cell::new_memory_operation_notification( notification, )); self.request_redraw(); } + #[cfg(test)] fn on_memory_operation_event(&mut self, event: MemoryOperationEvent) { + if self.try_complete_pending_memory_operation_event(&event) { + self.request_redraw(); + return; + } self.add_to_history(history_cell::new_memory_operation_event(event)); self.request_redraw(); } + fn show_pending_memory_operation(&mut self, cell: history_cell::MemoryHistoryCell) { + self.flush_active_cell(); + self.active_cell = Some(Box::new(cell)); + self.bump_active_cell_revision(); + self.request_redraw(); + } + + fn try_complete_pending_memory_operation_notification( + &mut self, + notification: &MemoryOperationNotification, + ) -> bool { + if notification.source != AppServerMemoryOperationSource::Human { + return false; + } + let Some(active) = self.active_cell.as_mut() else { + return false; + }; + let Some(memory) = active + .as_any_mut() + .downcast_mut::() + else { + return false; + }; + let operation = match notification.operation { + codex_app_server_protocol::MemoryOperationKind::Recall => { + history_cell::MemoryOperationKind::Recall + } + codex_app_server_protocol::MemoryOperationKind::Update => { + history_cell::MemoryOperationKind::Update + } + codex_app_server_protocol::MemoryOperationKind::Drop => { + history_cell::MemoryOperationKind::Drop + } + }; + if !memory.is_human_pending_submission(operation, notification.query.as_deref()) { + return false; + } + memory.apply_notification(notification.clone()); + self.bump_active_cell_revision(); + self.flush_active_cell(); + true + } + + #[cfg(test)] + fn try_complete_pending_memory_operation_event( + &mut self, + event: &MemoryOperationEvent, + ) -> bool { + if event.source != MemoryOperationSource::Human { + return false; + } + let Some(active) = self.active_cell.as_mut() else { + return false; + }; + let Some(memory) = active + .as_any_mut() + .downcast_mut::() + else { + return false; + }; + let operation = match event.operation { + codex_protocol::items::MemoryOperationKind::Recall => { + history_cell::MemoryOperationKind::Recall + } + codex_protocol::items::MemoryOperationKind::Update => { + history_cell::MemoryOperationKind::Update + } + codex_protocol::items::MemoryOperationKind::Drop => { + history_cell::MemoryOperationKind::Drop + } + }; + if !memory.is_human_pending_submission(operation, event.query.as_deref()) { + return false; + } + memory.apply_event(event.clone()); + self.bump_active_cell_revision(); + self.flush_active_cell(); + true + } + #[cfg(test)] fn on_mcp_startup_update(&mut self, ev: McpStartupUpdateEvent) { let mut status = self.mcp_startup_status.take().unwrap_or_default(); @@ -4512,7 +4605,8 @@ impl ChatWidget { widget.config.features.enabled(Feature::VoiceTranscription), ); widget.bottom_pane.set_agentmemory_enabled( - widget.config.memories.backend == codex_core::config::types::MemoryBackend::Agentmemory, + widget.config.memories.backend == codex_core::config::types::MemoryBackend::Agentmemory + && widget.config.features.enabled(Feature::MemoryTool), ); widget .bottom_pane @@ -5066,23 +5160,20 @@ impl ChatWidget { self.clean_background_terminals(); } SlashCommand::MemoryDrop => { - self.add_to_history(history_cell::new_memory_drop_submission()); - self.request_redraw(); + self.show_pending_memory_operation(history_cell::new_memory_drop_submission()); self.submit_op(AppCommand::memory_drop()); } SlashCommand::MemoryUpdate => { - self.add_to_history(history_cell::new_memory_update_submission()); - self.request_redraw(); + self.show_pending_memory_operation(history_cell::new_memory_update_submission()); self.submit_op(AppCommand::memory_update()); } SlashCommand::MemoryRecall => { if !self.ensure_memory_recall_thread() { return; } - self.add_to_history(history_cell::new_memory_recall_submission( + self.show_pending_memory_operation(history_cell::new_memory_recall_submission( /*query*/ None, )); - self.request_redraw(); self.submit_op(AppCommand::memory_recall(/*query*/ None)); } SlashCommand::Mcp => { @@ -5263,10 +5354,9 @@ impl ChatWidget { else { return; }; - self.add_to_history(history_cell::new_memory_recall_submission(Some( - trimmed.to_string(), - ))); - self.request_redraw(); + self.show_pending_memory_operation(history_cell::new_memory_recall_submission( + Some(trimmed.to_string()), + )); self.submit_op(AppCommand::memory_recall(Some(prepared_args))); self.bottom_pane.drain_pending_submission_state(); } diff --git a/codex-rs/tui_app_server/src/chatwidget/tests.rs b/codex-rs/tui_app_server/src/chatwidget/tests.rs index 71949bfe6..09272dfae 100644 --- a/codex-rs/tui_app_server/src/chatwidget/tests.rs +++ b/codex-rs/tui_app_server/src/chatwidget/tests.rs @@ -7289,17 +7289,15 @@ async fn slash_memory_drop_submits_core_op() { chat.dispatch_command(SlashCommand::MemoryDrop); - let event = rx.try_recv().expect("expected memory drop info"); - match event { - AppEvent::InsertHistoryCell(cell) => { - let rendered = lines_to_single_string(&cell.display_lines(80)); - assert!( - rendered.contains("Memory Drop"), - "expected memory drop info, got {rendered:?}" - ); - } - other => panic!("expected InsertHistoryCell info, got {other:?}"), - } + assert!( + active_blob(&chat).contains("Memory Drop"), + "expected active memory drop card, got {:?}", + active_blob(&chat) + ); + assert!( + rx.try_recv().is_err(), + "expected no committed history cell yet" + ); assert_matches!(op_rx.try_recv(), Ok(Op::DropMemories)); assert!(rx.try_recv().is_err(), "expected no stub message"); } @@ -7322,17 +7320,15 @@ async fn slash_memory_update_submits_core_op() { chat.dispatch_command(SlashCommand::MemoryUpdate); - let event = rx.try_recv().expect("expected memory update info"); - match event { - AppEvent::InsertHistoryCell(cell) => { - let rendered = lines_to_single_string(&cell.display_lines(80)); - assert!( - rendered.contains("Memory Update"), - "expected memory update info, got {rendered:?}" - ); - } - other => panic!("expected InsertHistoryCell info, got {other:?}"), - } + assert!( + active_blob(&chat).contains("Memory Update"), + "expected active memory update card, got {:?}", + active_blob(&chat) + ); + assert!( + rx.try_recv().is_err(), + "expected no committed history cell yet" + ); assert_matches!(op_rx.try_recv(), Ok(Op::UpdateMemories)); assert!(rx.try_recv().is_err(), "expected no stub message"); } @@ -7368,17 +7364,15 @@ async fn slash_memory_recall_submits_core_op() { chat.dispatch_command(SlashCommand::MemoryRecall); - let event = rx.try_recv().expect("expected memory recall info"); - match event { - AppEvent::InsertHistoryCell(cell) => { - let rendered = lines_to_single_string(&cell.display_lines(80)); - assert!( - rendered.contains("Memory Recall"), - "expected memory recall info, got {rendered:?}" - ); - } - other => panic!("expected InsertHistoryCell info, got {other:?}"), - } + assert!( + active_blob(&chat).contains("Memory Recall"), + "expected active memory recall card, got {:?}", + active_blob(&chat) + ); + assert!( + rx.try_recv().is_err(), + "expected no committed history cell yet" + ); assert_matches!(op_rx.try_recv(), Ok(Op::RecallMemories { query: None })); assert!(rx.try_recv().is_err(), "expected no stub message"); } @@ -7424,17 +7418,15 @@ async fn slash_memory_recall_with_inline_args_submits_query() { ); chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); - let event = rx.try_recv().expect("expected memory recall info"); - match event { - AppEvent::InsertHistoryCell(cell) => { - let rendered = lines_to_single_string(&cell.display_lines(80)); - assert!( - rendered.contains("Query: retrieval freshness"), - "expected memory recall info, got {rendered:?}" - ); - } - other => panic!("expected InsertHistoryCell info, got {other:?}"), - } + assert!( + active_blob(&chat).contains("Query: retrieval freshness"), + "expected active memory recall card, got {:?}", + active_blob(&chat) + ); + assert!( + rx.try_recv().is_err(), + "expected no committed history cell yet" + ); assert_matches!( op_rx.try_recv(), Ok(Op::RecallMemories { @@ -11457,15 +11449,15 @@ async fn app_server_memory_operation_notification_adds_memory_history_cell() { ServerNotification::MemoryOperation( codex_app_server_protocol::MemoryOperationNotification { thread_id: "thread-1".to_string(), + source: codex_app_server_protocol::MemoryOperationSource::Assistant, operation: codex_app_server_protocol::MemoryOperationKind::Recall, status: codex_app_server_protocol::MemoryOperationStatus::Ready, query: Some("retrieval freshness".to_string()), - summary: "Recalled memory context and injected it into the current thread." - .to_string(), + summary: "Assistant recalled memory context for this turn.".to_string(), detail: Some( "remember this".to_string(), ), - context_injected: true, + context_injected: false, }, ), None, @@ -11482,12 +11474,66 @@ async fn app_server_memory_operation_notification_adds_memory_history_cell() { rendered.contains("Query: retrieval freshness"), "missing memory query: {rendered}" ); + assert!( + rendered.contains("Source: assistant tool"), + "missing assistant source label: {rendered}" + ); assert!( rendered.contains("remember this"), "missing memory detail: {rendered}" ); } +#[tokio::test] +async fn app_server_human_memory_recall_completion_replaces_pending_card_in_place() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; + chat.bottom_pane.set_agentmemory_enabled(true); + chat.thread_id = Some(ThreadId::new()); + + chat.bottom_pane.set_composer_text( + "/memory-recall retrieval freshness".to_string(), + Vec::new(), + Vec::new(), + ); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + assert_matches!( + op_rx.try_recv(), + Ok(Op::RecallMemories { + query: Some(query) + }) if query == "retrieval freshness" + ); + assert!( + rx.try_recv().is_err(), + "expected pending card to stay active" + ); + + chat.handle_server_notification( + ServerNotification::MemoryOperation( + codex_app_server_protocol::MemoryOperationNotification { + thread_id: "thread-1".to_string(), + source: codex_app_server_protocol::MemoryOperationSource::Human, + operation: codex_app_server_protocol::MemoryOperationKind::Recall, + status: codex_app_server_protocol::MemoryOperationStatus::Ready, + query: Some("retrieval freshness".to_string()), + summary: "Recalled memory context and injected it into the current thread." + .to_string(), + detail: Some( + "remember this".to_string(), + ), + context_injected: true, + }, + ), + None, + ); + + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 1, "expected a single completed memory card"); + let rendered = lines_to_single_string(&cells[0]); + assert!(rendered.contains("Memory Recall Ready")); + assert!(rendered.contains("Query: retrieval freshness")); + assert!(rx.try_recv().is_err(), "expected no extra history insert"); +} + #[tokio::test] async fn app_server_guardian_review_denied_renders_denied_request_snapshot() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; diff --git a/codex-rs/tui_app_server/src/history_cell.rs b/codex-rs/tui_app_server/src/history_cell.rs index b995fd7b6..836a1e9df 100644 --- a/codex-rs/tui_app_server/src/history_cell.rs +++ b/codex-rs/tui_app_server/src/history_cell.rs @@ -64,6 +64,7 @@ use codex_protocol::plan_tool::UpdatePlanArgs; use codex_protocol::protocol::FileChange; use codex_protocol::protocol::McpAuthStatus; use codex_protocol::protocol::McpInvocation; +#[cfg(test)] use codex_protocol::protocol::MemoryOperationEvent; use codex_protocol::protocol::SessionConfiguredEvent; use codex_protocol::request_user_input::RequestUserInputAnswer; @@ -2192,8 +2193,15 @@ enum MemoryOperationState { Error, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum MemoryOperationSource { + Human, + Assistant, +} + #[derive(Debug)] pub(crate) struct MemoryHistoryCell { + source: MemoryOperationSource, operation: MemoryOperationKind, state: MemoryOperationState, query: Option, @@ -2203,6 +2211,7 @@ pub(crate) struct MemoryHistoryCell { impl MemoryHistoryCell { fn new( + source: MemoryOperationSource, operation: MemoryOperationKind, state: MemoryOperationState, query: Option, @@ -2210,6 +2219,7 @@ impl MemoryHistoryCell { detail: Option, ) -> Self { Self { + source, operation, state, query, @@ -2234,6 +2244,74 @@ impl MemoryHistoryCell { .as_deref() .map(|detail| truncate_text(detail, MEMORY_PREVIEW_MAX_GRAPHEMES)) } + + pub(crate) fn is_human_pending_submission( + &self, + operation: MemoryOperationKind, + query: Option<&str>, + ) -> bool { + self.source == MemoryOperationSource::Human + && self.state == MemoryOperationState::Pending + && self.operation == operation + && self.query.as_deref().map(str::trim) == query.map(str::trim) + } + + #[cfg(test)] + pub(crate) fn apply_event(&mut self, event: MemoryOperationEvent) { + self.source = match event.source { + codex_protocol::protocol::MemoryOperationSource::Human => MemoryOperationSource::Human, + codex_protocol::protocol::MemoryOperationSource::Assistant => { + MemoryOperationSource::Assistant + } + }; + self.operation = match event.operation { + codex_protocol::items::MemoryOperationKind::Recall => MemoryOperationKind::Recall, + codex_protocol::items::MemoryOperationKind::Update => MemoryOperationKind::Update, + codex_protocol::items::MemoryOperationKind::Drop => MemoryOperationKind::Drop, + }; + self.state = match event.status { + codex_protocol::items::MemoryOperationStatus::Pending => MemoryOperationState::Pending, + codex_protocol::items::MemoryOperationStatus::Ready => MemoryOperationState::Success, + codex_protocol::items::MemoryOperationStatus::Empty => MemoryOperationState::Empty, + codex_protocol::items::MemoryOperationStatus::Error => MemoryOperationState::Error, + }; + self.query = event.query; + self.summary = event.summary; + self.detail = event + .detail + .map(|detail| detail.trim().to_string()) + .filter(|detail| !detail.is_empty()); + } + + pub(crate) fn apply_notification(&mut self, notification: MemoryOperationNotification) { + self.source = match notification.source { + codex_app_server_protocol::MemoryOperationSource::Human => MemoryOperationSource::Human, + codex_app_server_protocol::MemoryOperationSource::Assistant => { + MemoryOperationSource::Assistant + } + }; + self.operation = match notification.operation { + codex_app_server_protocol::MemoryOperationKind::Recall => MemoryOperationKind::Recall, + codex_app_server_protocol::MemoryOperationKind::Update => MemoryOperationKind::Update, + codex_app_server_protocol::MemoryOperationKind::Drop => MemoryOperationKind::Drop, + }; + self.state = match notification.status { + codex_app_server_protocol::MemoryOperationStatus::Pending => { + MemoryOperationState::Pending + } + codex_app_server_protocol::MemoryOperationStatus::Ready => { + MemoryOperationState::Success + } + codex_app_server_protocol::MemoryOperationStatus::Empty => MemoryOperationState::Empty, + codex_app_server_protocol::MemoryOperationStatus::Error => MemoryOperationState::Error, + }; + self.query = notification.query; + self.summary = notification.summary; + self.detail = notification + .detail + .map(|detail| detail.trim().to_string()) + .filter(|detail| !detail.is_empty()); + } } impl HistoryCell for MemoryHistoryCell { @@ -2255,6 +2333,10 @@ impl HistoryCell for MemoryHistoryCell { push_owned_lines(&wrapped, &mut lines); } + if self.source == MemoryOperationSource::Assistant { + lines.push(Line::from(vec![" Source: ".dim(), "assistant tool".dim()])); + } + let summary_line = Line::from(vec![" ".into(), self.summary.clone().into()]); let wrapped_summary = adaptive_wrap_line(&summary_line, RtOptions::new(wrap_width)); push_owned_lines(&wrapped_summary, &mut lines); @@ -2277,6 +2359,7 @@ impl HistoryCell for MemoryHistoryCell { pub(crate) fn new_memory_recall_submission(query: Option) -> MemoryHistoryCell { MemoryHistoryCell::new( + MemoryOperationSource::Human, MemoryOperationKind::Recall, MemoryOperationState::Pending, query, @@ -2287,6 +2370,7 @@ pub(crate) fn new_memory_recall_submission(query: Option) -> MemoryHisto pub(crate) fn new_memory_update_submission() -> MemoryHistoryCell { MemoryHistoryCell::new( + MemoryOperationSource::Human, MemoryOperationKind::Update, MemoryOperationState::Pending, /*query*/ None, @@ -2297,6 +2381,7 @@ pub(crate) fn new_memory_update_submission() -> MemoryHistoryCell { pub(crate) fn new_memory_drop_submission() -> MemoryHistoryCell { MemoryHistoryCell::new( + MemoryOperationSource::Human, MemoryOperationKind::Drop, MemoryOperationState::Pending, /*query*/ None, @@ -2307,6 +2392,7 @@ pub(crate) fn new_memory_drop_submission() -> MemoryHistoryCell { pub(crate) fn new_memory_recall_thread_requirement() -> MemoryHistoryCell { MemoryHistoryCell::new( + MemoryOperationSource::Human, MemoryOperationKind::Recall, MemoryOperationState::Error, /*query*/ None, @@ -2315,8 +2401,15 @@ pub(crate) fn new_memory_recall_thread_requirement() -> MemoryHistoryCell { ) } +#[cfg(test)] pub(crate) fn new_memory_operation_event(event: MemoryOperationEvent) -> MemoryHistoryCell { MemoryHistoryCell::new( + match event.source { + codex_protocol::protocol::MemoryOperationSource::Human => MemoryOperationSource::Human, + codex_protocol::protocol::MemoryOperationSource::Assistant => { + MemoryOperationSource::Assistant + } + }, match event.operation { codex_protocol::items::MemoryOperationKind::Recall => MemoryOperationKind::Recall, codex_protocol::items::MemoryOperationKind::Update => MemoryOperationKind::Update, @@ -2338,6 +2431,12 @@ pub(crate) fn new_memory_operation_notification( notification: MemoryOperationNotification, ) -> MemoryHistoryCell { MemoryHistoryCell::new( + match notification.source { + codex_app_server_protocol::MemoryOperationSource::Human => MemoryOperationSource::Human, + codex_app_server_protocol::MemoryOperationSource::Assistant => { + MemoryOperationSource::Assistant + } + }, match notification.operation { codex_app_server_protocol::MemoryOperationKind::Recall => MemoryOperationKind::Recall, codex_app_server_protocol::MemoryOperationKind::Update => MemoryOperationKind::Update, @@ -3331,6 +3430,7 @@ mod tests { #[test] fn memory_recall_result_snapshot() { let cell = new_memory_operation_event(MemoryOperationEvent { + source: codex_protocol::protocol::MemoryOperationSource::Human, operation: codex_protocol::items::MemoryOperationKind::Recall, status: codex_protocol::items::MemoryOperationStatus::Ready, query: Some("retrieval freshness".to_string()), From b93aba4fab1353e03d692eff332d4c597fa4a641 Mon Sep 17 00:00:00 2001 From: Eric Juta Date: Mon, 30 Mar 2026 12:36:57 +0100 Subject: [PATCH 40/45] Fix argument-comment-lint invocation from codex-rs Resolve the root justfile wrappers relative to the justfile directory and fall back to the source runner when dotslash is unavailable, so just argument-comment-lint works from codex-rs checkouts. Co-authored-by: Codex --- justfile | 4 ++-- tools/argument-comment-lint/run-prebuilt-linter.sh | 8 ++------ 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/justfile b/justfile index 5c9fa5e6a..326324199 100644 --- a/justfile +++ b/justfile @@ -94,11 +94,11 @@ write-hooks-schema: # Run the argument-comment Dylint checks across codex-rs. [no-cd] argument-comment-lint *args: - ./tools/argument-comment-lint/run-prebuilt-linter.sh "$@" + {{justfile_directory()}}/tools/argument-comment-lint/run-prebuilt-linter.sh "$@" [no-cd] argument-comment-lint-from-source *args: - ./tools/argument-comment-lint/run.sh "$@" + {{justfile_directory()}}/tools/argument-comment-lint/run.sh "$@" # Tail logs from the state SQLite database log *args: diff --git a/tools/argument-comment-lint/run-prebuilt-linter.sh b/tools/argument-comment-lint/run-prebuilt-linter.sh index 3828e06d9..88562986b 100755 --- a/tools/argument-comment-lint/run-prebuilt-linter.sh +++ b/tools/argument-comment-lint/run-prebuilt-linter.sh @@ -73,12 +73,8 @@ fi lint_args+=("$@") if ! command -v dotslash >/dev/null 2>&1; then - cat >&2 <&2 + exec "$repo_root/tools/argument-comment-lint/run.sh" "$@" fi if command -v rustup >/dev/null 2>&1; then From 7637de88e926e17623b7eca7e41e2b7f04ec9f62 Mon Sep 17 00:00:00 2001 From: Eric Juta Date: Mon, 30 Mar 2026 13:10:36 +0100 Subject: [PATCH 41/45] docs: clarify public source licensing and fork intent Document the public-source compliance posture, expand third-party notice coverage, rename the release guidance to fork intent, and clean the Rust license-audit config. Co-authored-by: Codex --- NOTICE | 16 ++++-- README.md | 5 +- codex-rs/deny.toml | 3 -- docs/fork-intent.md | 115 ++++++++++++++++++++++++++++++++++++++++++++ docs/license.md | 41 +++++++++++++++- package.json | 1 + 6 files changed, 173 insertions(+), 8 deletions(-) create mode 100644 docs/fork-intent.md diff --git a/NOTICE b/NOTICE index 2a64a45aa..8418de352 100644 --- a/NOTICE +++ b/NOTICE @@ -1,9 +1,19 @@ OpenAI Codex -Copyright 2025 OpenAI +Copyright 2025 OpenAI -This project includes code derived from [Ratatui](https://github.com/ratatui/ratatui), licensed under the MIT license. +This project includes code derived from Ratatui (https://github.com/ratatui/ratatui), +licensed under the MIT license. Copyright (c) 2016-2022 Florian Dehau Copyright (c) 2023-2025 The Ratatui Developers -This project includes Meriyah parser assets from [meriyah](https://github.com/meriyah/meriyah), licensed under the ISC license. +This project includes Windows PTY support code copied from WezTerm +(https://github.com/wezterm/wezterm), licensed under the MIT license. +Copyright (c) 2018-Present Wez Furlong + +This project includes Meriyah parser assets from meriyah +(https://github.com/meriyah/meriyah), licensed under the ISC license. Copyright (c) 2019 and later, KFlash and others. + +This project vendors bubblewrap source code under codex-rs/vendor/bubblewrap, +licensed under LGPL-2.0-or-later. See codex-rs/vendor/bubblewrap/COPYING for +the full license text and upstream copyright notices. diff --git a/README.md b/README.md index 1e44875f2..230e4d812 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,9 @@ You can also use Codex with an API key, but this requires [additional setup](htt - [**Codex Documentation**](https://developers.openai.com/codex) - [**Contributing**](./docs/contributing.md) - [**Installing & building**](./docs/install.md) +- [**Fork Intent**](./docs/fork-intent.md) - [**Open source fund**](./docs/open-source-fund.md) -This repository is licensed under the [Apache-2.0 License](LICENSE). +This repository is licensed under the [Apache-2.0 License](LICENSE). See +[NOTICE](NOTICE) for bundled third-party attributions and +[docs/license.md](docs/license.md) for repo-specific licensing notes. diff --git a/codex-rs/deny.toml b/codex-rs/deny.toml index 3dd27c8f9..ccd510e7e 100644 --- a/codex-rs/deny.toml +++ b/codex-rs/deny.toml @@ -128,9 +128,6 @@ allow = [ # MPL-2.0 - https://www.mozilla.org/MPL/2.0/ # Used by: nucleo-matcher "MPL-2.0", - # OpenSSL - https://spdx.org/licenses/OpenSSL.html - # Used by: aws-lc-sys - "OpenSSL", # Unicode-3.0 - https://opensource.org/license/unicode # Used by: icu_decimal, icu_locale_core, icu_provider "Unicode-3.0", diff --git a/docs/fork-intent.md b/docs/fork-intent.md new file mode 100644 index 000000000..7b4112fd2 --- /dev/null +++ b/docs/fork-intent.md @@ -0,0 +1,115 @@ +## Public Release Notes + +This document records the current licensing and release posture for making the +repository public and for shipping public release artifacts. + +Current intent: this is a publicly available source repository. Official public +Linux binary releases are not currently provided by this document. + +## Scope + +There are two separate compliance questions: + +1. Can the source repository be public? +2. Can we distribute public binaries built from this repository? + +The answer to the first is mostly a documentation and attribution question. The +answer to the second also depends on what third-party code is compiled into the +released artifacts. + +## Repository Authored Code + +Repository-authored code is licensed under [Apache-2.0](../LICENSE), unless a +particular file or bundled third-party subtree states otherwise. + +The root [NOTICE](../NOTICE) file and [license.md](./license.md) document the +non-Apache material currently kept in-tree. + +## Bundled Third-Party Material + +The repository currently includes the following notable third-party materials: + +- Ratatui-derived files in `codex-rs/tui/src/custom_terminal.rs` and + `codex-rs/tui_app_server/src/custom_terminal.rs`, with inline MIT notices. +- WezTerm-derived Windows PTY files in `codex-rs/utils/pty/src/win/`, with + inline MIT notices and local-modification notes. +- A bundled Meriyah parser asset at + `codex-rs/core/src/tools/js_repl/meriyah.umd.min.js`, with its license kept + at `third_party/meriyah/LICENSE`. +- A vendored bubblewrap source tree at `codex-rs/vendor/bubblewrap`, under + `LGPL-2.0-or-later`. + +## Source Repository Publication + +For a public source repository, the baseline requirements are: + +- Keep the root `LICENSE` file. +- Keep the root `NOTICE` file accurate when new bundled third-party material is + added or removed. +- Preserve inline notices on copied or derived files. +- Keep bundled third-party license texts in-tree when referenced by shipped + assets. +- Continue running `cargo deny check licenses` for the Rust workspace. + +This repository currently meets that baseline more cleanly than before, but it +still requires release discipline when third-party code is updated. + +## Binary Distribution + +Binary distribution needs a stricter release gate than source publication. +Public binaries should ship with: + +- `LICENSE` +- `NOTICE` +- any third-party license texts required by bundled or compiled components +- release notes that explain material third-party inclusions when relevant + +If a release artifact includes code under obligations beyond simple attribution, +the release process must explicitly account for that component. + +## Vendored Bubblewrap + +This is the main component that needs product and legal clarity before broad +public Linux binary distribution. + +Current state: + +- `codex-rs/linux-sandbox/build.rs` compiles vendored bubblewrap C sources on + Linux targets. +- `codex-rs/linux-sandbox/src/vendored_bwrap.rs` exposes that compiled entry + point for runtime use. +- `codex-rs/linux-sandbox/README.md` documents that the helper prefers system + `/usr/bin/bwrap`, but falls back to the vendored build path when needed. + +That means vendored bubblewrap is not just present in source form; it can also +be part of Linux builds and therefore affects binary-distribution compliance. + +## Recommendation + +Default recommendation: do not ship public Linux release binaries that rely on +the vendored bubblewrap fallback until that lane has an explicit legal and +release-process owner. + +Preferred short-term approach: + +- Make public Linux release builds rely on system `bwrap`, or otherwise disable + the vendored fallback in distributed binaries. +- Keep the vendored bubblewrap tree in source if it is still useful for local + development, CI, or non-public builds. +- Revisit vendored-bubblewrap distribution only with a dedicated compliance + review. + +If the project later decides to ship vendored bubblewrap in public binaries, the +release process should be updated deliberately rather than relying on the source +repository notices alone. + +## Working Rule + +Until a separate decision is recorded, treat these as the default release rules: + +- Public source repo: allowed with current notices and license files kept up to + date. +- Public Linux binaries using vendored bubblewrap: not allowed by default. +- Public Linux binaries using system bubblewrap only: preferred interim path, + subject to normal release review. +- No official public Linux release build pipeline is assumed by this document. diff --git a/docs/license.md b/docs/license.md index 18ad62af0..509d854d1 100644 --- a/docs/license.md +++ b/docs/license.md @@ -1,3 +1,42 @@ ## License -This repository is licensed under the [Apache-2.0 License](../LICENSE). +This repository is licensed under the [Apache-2.0 License](../LICENSE). Unless +an individual file or bundled third-party directory says otherwise, that is the +license that applies to repository-authored code. + +## Bundled Third-Party Material + +Some files shipped in this repository remain under their upstream licenses and +keep their original notices: + +- codex-rs/tui/src/custom_terminal.rs and + codex-rs/tui_app_server/src/custom_terminal.rs are derived from Ratatui and + retain MIT notices inline. +- codex-rs/utils/pty/src/win/ contains Windows PTY support code copied from + WezTerm and retains MIT notices inline. A copy of the upstream license is + available at third_party/wezterm/LICENSE. +- codex-rs/core/src/tools/js_repl/meriyah.umd.min.js bundles a Meriyah parser + asset under the ISC license. A copy of that license is available at + third_party/meriyah/LICENSE. +- codex-rs/vendor/bubblewrap/ vendors bubblewrap source code under + LGPL-2.0-or-later. The full license text is at + codex-rs/vendor/bubblewrap/COPYING. + +The root [NOTICE](../NOTICE) file summarizes the bundled third-party materials +that currently require explicit attribution in this source tree. + +For release and publication guidance, including the current recommendation for +vendored bubblewrap, see [fork-intent.md](./fork-intent.md). + +## Package Metadata + +Published Rust crates under codex-rs, the JavaScript packages under codex-cli, +and the Python and TypeScript SDK packages under sdk/ declare Apache-2.0 in +their package metadata. + +## Dependency Auditing + +Rust dependency licenses are checked with cargo-deny: + + cd codex-rs + cargo deny check licenses diff --git a/package.json b/package.json index 504b12bee..42266471c 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "name": "codex-monorepo", "private": true, "description": "Tools for repo-wide maintenance.", + "license": "Apache-2.0", "scripts": { "format": "prettier --check *.json *.md docs/*.md .github/workflows/*.yml **/*.js", "format:fix": "prettier --write *.json *.md docs/*.md .github/workflows/*.yml **/*.js", From 8573eaea3338e2d9816f6f8e529cd6a854618b04 Mon Sep 17 00:00:00 2001 From: Eric Juta Date: Mon, 30 Mar 2026 14:56:40 +0100 Subject: [PATCH 42/45] docs: split fork intent from release notes Separate the fork-rationale doc from the public release and legal guidance, and surface both from the root README. Co-authored-by: Codex --- README.md | 1 + docs/fork-intent.md | 176 +++++++++++++---------------------- docs/license.md | 2 +- docs/public-release-notes.md | 118 +++++++++++++++++++++++ 4 files changed, 183 insertions(+), 114 deletions(-) create mode 100644 docs/public-release-notes.md diff --git a/README.md b/README.md index 230e4d812..59c1da1d6 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,7 @@ You can also use Codex with an API key, but this requires [additional setup](htt - [**Contributing**](./docs/contributing.md) - [**Installing & building**](./docs/install.md) - [**Fork Intent**](./docs/fork-intent.md) +- [**Public Release Notes**](./docs/public-release-notes.md) - [**Open source fund**](./docs/open-source-fund.md) This repository is licensed under the [Apache-2.0 License](LICENSE). See diff --git a/docs/fork-intent.md b/docs/fork-intent.md index 7b4112fd2..fff270177 100644 --- a/docs/fork-intent.md +++ b/docs/fork-intent.md @@ -1,115 +1,65 @@ -## Public Release Notes - -This document records the current licensing and release posture for making the -repository public and for shipping public release artifacts. - -Current intent: this is a publicly available source repository. Official public -Linux binary releases are not currently provided by this document. - -## Scope - -There are two separate compliance questions: - -1. Can the source repository be public? -2. Can we distribute public binaries built from this repository? - -The answer to the first is mostly a documentation and attribution question. The -answer to the second also depends on what third-party code is compiled into the -released artifacts. - -## Repository Authored Code - -Repository-authored code is licensed under [Apache-2.0](../LICENSE), unless a -particular file or bundled third-party subtree states otherwise. - -The root [NOTICE](../NOTICE) file and [license.md](./license.md) document the -non-Apache material currently kept in-tree. - -## Bundled Third-Party Material - -The repository currently includes the following notable third-party materials: - -- Ratatui-derived files in `codex-rs/tui/src/custom_terminal.rs` and - `codex-rs/tui_app_server/src/custom_terminal.rs`, with inline MIT notices. -- WezTerm-derived Windows PTY files in `codex-rs/utils/pty/src/win/`, with - inline MIT notices and local-modification notes. -- A bundled Meriyah parser asset at - `codex-rs/core/src/tools/js_repl/meriyah.umd.min.js`, with its license kept - at `third_party/meriyah/LICENSE`. -- A vendored bubblewrap source tree at `codex-rs/vendor/bubblewrap`, under - `LGPL-2.0-or-later`. - -## Source Repository Publication - -For a public source repository, the baseline requirements are: - -- Keep the root `LICENSE` file. -- Keep the root `NOTICE` file accurate when new bundled third-party material is - added or removed. -- Preserve inline notices on copied or derived files. -- Keep bundled third-party license texts in-tree when referenced by shipped - assets. -- Continue running `cargo deny check licenses` for the Rust workspace. - -This repository currently meets that baseline more cleanly than before, but it -still requires release discipline when third-party code is updated. +# Fork Intent + +This repository is a public fork of +[openai/codex](https://github.com/openai/codex). + +The intent of this fork is not to rename or replace the upstream project. The +intent is to keep a Codex-compatible fork while adapting the runtime to use +`agentmemory` as the primary long-term memory backend, expand the public hook +surface toward Claude-style lifecycle parity, keep the memory UX coherent +across both TUIs, and trim hosted CI and release machinery to the lanes this +fork actually needs. + +This document explains why this fork exists and which parts of the tree changed +to support that goal. + +For the release and legal posture for making the repository public and for +shipping public release artifacts, see +[`docs/public-release-notes.md`](./public-release-notes.md). + +## Fork Goals + +1. Make `agentmemory` the authoritative memory engine instead of keeping two + competing long-term memory systems. +2. Expose a runtime memory surface that is coherent for both humans and the + assistant across `tui` and `tui_app_server`. +3. Move Codex hooks closer to Claude Code's documented lifecycle model so + existing hook setups port with fewer custom patches. +4. Keep the fork operationally legible by retaining only the CI, docs, and + release machinery that still matters here. +5. Keep provenance, licensing, and release constraints explicit rather than + burying fork-specific decisions in commit history. + +## Change Map + +| Area | Intent | Key files | +|---|---|---| +| Memory backend replacement | Make `agentmemory` the primary long-term memory engine and bypass native Codex memory generation when that backend is selected. | [`docs/agentmemory-codex-memory-replacement-spec.md`](./agentmemory-codex-memory-replacement-spec.md), [`../codex-rs/core/src/agentmemory/mod.rs`](../codex-rs/core/src/agentmemory/mod.rs), [`../codex-rs/core/src/codex.rs`](../codex-rs/core/src/codex.rs), [`../codex-rs/core/src/memories/phase2.rs`](../codex-rs/core/src/memories/phase2.rs), [`../codex-rs/core/src/memories/tests.rs`](../codex-rs/core/src/memories/tests.rs) | +| Runtime memory surface and UX | Keep memory recall/update/drop visible and coherent across both TUIs, with assistant-triggered recall routed through the same backend semantics. | [`../codex-rs/docs/agentmemory_runtime_surface_spec.md`](../codex-rs/docs/agentmemory_runtime_surface_spec.md), [`../codex-rs/docs/agentmemory_followup_spec.md`](../codex-rs/docs/agentmemory_followup_spec.md), [`../codex-rs/tui/src/chatwidget.rs`](../codex-rs/tui/src/chatwidget.rs), [`../codex-rs/tui/src/bottom_pane/footer.rs`](../codex-rs/tui/src/bottom_pane/footer.rs), [`../codex-rs/tui_app_server/src/chatwidget.rs`](../codex-rs/tui_app_server/src/chatwidget.rs), [`../codex-rs/tui_app_server/src/bottom_pane/footer.rs`](../codex-rs/tui_app_server/src/bottom_pane/footer.rs) | +| Hook parity and lifecycle capture | Expand the public `hooks.json` surface so Claude-oriented hook configurations map onto Codex with fewer surprises and clearer runtime contracts. | [`./claude-code-hooks-parity.md`](./claude-code-hooks-parity.md), [`../codex-rs/hooks/README.md`](../codex-rs/hooks/README.md), [`../codex-rs/hooks/src/engine/config.rs`](../codex-rs/hooks/src/engine/config.rs), [`../codex-rs/hooks/src/engine/discovery.rs`](../codex-rs/hooks/src/engine/discovery.rs), [`../codex-rs/hooks/src/engine/dispatcher.rs`](../codex-rs/hooks/src/engine/dispatcher.rs), [`../codex-rs/hooks/src/schema.rs`](../codex-rs/hooks/src/schema.rs) | +| Fork-scoped CI and release posture | Remove or narrow upstream maintainer workflows that do not add value in this fork, while keeping enough CI and packaging signal for the paths still used here. | [`../codex-rs/docs/github_actions_private_fork_spec.md`](../codex-rs/docs/github_actions_private_fork_spec.md), [`../.github/workflows/rust-ci.yml`](../.github/workflows/rust-ci.yml), [`../.github/workflows/cargo-deny.yml`](../.github/workflows/cargo-deny.yml), [`../.github/workflows/ci.bazelrc`](../.github/workflows/ci.bazelrc), [`../.github/workflows/v8-ci.bazelrc`](../.github/workflows/v8-ci.bazelrc), [`../scripts/stage_npm_packages.py`](../scripts/stage_npm_packages.py) | +| Public source and licensing clarity | Keep the fork publishable as source, preserve third-party notices, and document the remaining constraints around public binary distribution. | [`../README.md`](../README.md), [`./license.md`](./license.md), [`../NOTICE`](../NOTICE), [`../LICENSE`](../LICENSE) | + +## What This Fork Is Not + +- It is not a claim to authorship over upstream `openai/codex`. +- It is not a separate product with a new license or package identity. +- It is not a promise that upstream release automation or contributor-governance + workflows remain enabled here. +- It is not a statement that this repository currently provides official public + Linux binaries. + +## Related Docs + +- [`README.md`](../README.md) +- [`docs/agentmemory-codex-memory-replacement-spec.md`](./agentmemory-codex-memory-replacement-spec.md) +- [`docs/claude-code-hooks-parity.md`](./claude-code-hooks-parity.md) +- [`codex-rs/docs/agentmemory_runtime_surface_spec.md`](../codex-rs/docs/agentmemory_runtime_surface_spec.md) +- [`codex-rs/docs/github_actions_private_fork_spec.md`](../codex-rs/docs/github_actions_private_fork_spec.md) +- [`docs/public-release-notes.md`](./public-release-notes.md) +- [`docs/license.md`](./license.md) -## Binary Distribution - -Binary distribution needs a stricter release gate than source publication. -Public binaries should ship with: - -- `LICENSE` -- `NOTICE` -- any third-party license texts required by bundled or compiled components -- release notes that explain material third-party inclusions when relevant - -If a release artifact includes code under obligations beyond simple attribution, -the release process must explicitly account for that component. - -## Vendored Bubblewrap - -This is the main component that needs product and legal clarity before broad -public Linux binary distribution. - -Current state: - -- `codex-rs/linux-sandbox/build.rs` compiles vendored bubblewrap C sources on - Linux targets. -- `codex-rs/linux-sandbox/src/vendored_bwrap.rs` exposes that compiled entry - point for runtime use. -- `codex-rs/linux-sandbox/README.md` documents that the helper prefers system - `/usr/bin/bwrap`, but falls back to the vendored build path when needed. - -That means vendored bubblewrap is not just present in source form; it can also -be part of Linux builds and therefore affects binary-distribution compliance. - -## Recommendation - -Default recommendation: do not ship public Linux release binaries that rely on -the vendored bubblewrap fallback until that lane has an explicit legal and -release-process owner. - -Preferred short-term approach: - -- Make public Linux release builds rely on system `bwrap`, or otherwise disable - the vendored fallback in distributed binaries. -- Keep the vendored bubblewrap tree in source if it is still useful for local - development, CI, or non-public builds. -- Revisit vendored-bubblewrap distribution only with a dedicated compliance - review. - -If the project later decides to ship vendored bubblewrap in public binaries, the -release process should be updated deliberately rather than relying on the source -repository notices alone. - -## Working Rule - -Until a separate decision is recorded, treat these as the default release rules: +## Public Release Notes -- Public source repo: allowed with current notices and license files kept up to - date. -- Public Linux binaries using vendored bubblewrap: not allowed by default. -- Public Linux binaries using system bubblewrap only: preferred interim path, - subject to normal release review. -- No official public Linux release build pipeline is assumed by this document. +The release and legal guidance now lives in +[`docs/public-release-notes.md`](./public-release-notes.md). diff --git a/docs/license.md b/docs/license.md index 509d854d1..be3047ad9 100644 --- a/docs/license.md +++ b/docs/license.md @@ -26,7 +26,7 @@ The root [NOTICE](../NOTICE) file summarizes the bundled third-party materials that currently require explicit attribution in this source tree. For release and publication guidance, including the current recommendation for -vendored bubblewrap, see [fork-intent.md](./fork-intent.md). +vendored bubblewrap, see [public-release-notes.md](./public-release-notes.md). ## Package Metadata diff --git a/docs/public-release-notes.md b/docs/public-release-notes.md new file mode 100644 index 000000000..fd4e5813e --- /dev/null +++ b/docs/public-release-notes.md @@ -0,0 +1,118 @@ +# Public Release Notes + +This document records the current licensing and release posture for making the +repository public and for shipping public release artifacts. + +For the fork-rationale document that explains why this repository diverges from +upstream `openai/codex`, see [fork-intent.md](./fork-intent.md). + +Current intent: this is a publicly available source repository. Official public +Linux binary releases are not currently provided by this document. + +## Scope + +There are two separate compliance questions: + +1. Can the source repository be public? +2. Can we distribute public binaries built from this repository? + +The answer to the first is mostly a documentation and attribution question. The +answer to the second also depends on what third-party code is compiled into the +released artifacts. + +## Repository Authored Code + +Repository-authored code is licensed under [Apache-2.0](../LICENSE), unless a +particular file or bundled third-party subtree states otherwise. + +The root [NOTICE](../NOTICE) file and [license.md](./license.md) document the +non-Apache material currently kept in-tree. + +## Bundled Third-Party Material + +The repository currently includes the following notable third-party materials: + +- Ratatui-derived files in `codex-rs/tui/src/custom_terminal.rs` and + `codex-rs/tui_app_server/src/custom_terminal.rs`, with inline MIT notices. +- WezTerm-derived Windows PTY files in `codex-rs/utils/pty/src/win/`, with + inline MIT notices and local-modification notes. +- A bundled Meriyah parser asset at + `codex-rs/core/src/tools/js_repl/meriyah.umd.min.js`, with its license kept + at `third_party/meriyah/LICENSE`. +- A vendored bubblewrap source tree at `codex-rs/vendor/bubblewrap`, under + `LGPL-2.0-or-later`. + +## Source Repository Publication + +For a public source repository, the baseline requirements are: + +- Keep the root `LICENSE` file. +- Keep the root `NOTICE` file accurate when new bundled third-party material is + added or removed. +- Preserve inline notices on copied or derived files. +- Keep bundled third-party license texts in-tree when referenced by shipped + assets. +- Continue running `cargo deny check licenses` for the Rust workspace. + +This repository currently meets that baseline more cleanly than before, but it +still requires release discipline when third-party code is updated. + +## Binary Distribution + +Binary distribution needs a stricter release gate than source publication. +Public binaries should ship with: + +- `LICENSE` +- `NOTICE` +- any third-party license texts required by bundled or compiled components +- release notes that explain material third-party inclusions when relevant + +If a release artifact includes code under obligations beyond simple attribution, +the release process must explicitly account for that component. + +## Vendored Bubblewrap + +This is the main component that needs product and legal clarity before broad +public Linux binary distribution. + +Current state: + +- `codex-rs/linux-sandbox/build.rs` compiles vendored bubblewrap C sources on + Linux targets. +- `codex-rs/linux-sandbox/src/vendored_bwrap.rs` exposes that compiled entry + point for runtime use. +- `codex-rs/linux-sandbox/README.md` documents that the helper prefers system + `/usr/bin/bwrap`, but falls back to the vendored build path when needed. + +That means vendored bubblewrap is not just present in source form; it can also +be part of Linux builds and therefore affects binary-distribution compliance. + +## Recommendation + +Default recommendation: do not ship public Linux release binaries that rely on +the vendored bubblewrap fallback until that lane has an explicit legal and +release-process owner. + +Preferred short-term approach: + +- Make public Linux release builds rely on system `bwrap`, or otherwise disable + the vendored fallback in distributed binaries. +- Keep the vendored bubblewrap tree in source if it is still useful for local + development, CI, or non-public builds. +- Revisit vendored-bubblewrap distribution only with a dedicated compliance + review. + +If the project later decides to ship vendored bubblewrap in public binaries, the +release process should be updated deliberately rather than relying on the source +repository notices alone. + +## Working Rule + +Until a separate decision is recorded, treat these as the default release rules: + +- Public source repo: allowed with current notices and license files kept up to + date. +- Public Linux binaries using vendored bubblewrap: not allowed by default. +- Public Linux binaries using system bubblewrap only: preferred interim path, + subject to normal release review. +- No official public Linux release build pipeline is assumed by this document. From 9a6bcdaf00c0a7f4b170bd196c7c943f323e5e65 Mon Sep 17 00:00:00 2001 From: Eric Juta Date: Mon, 30 Mar 2026 23:59:34 +0100 Subject: [PATCH 43/45] perf(cli): add optional mimalloc allocator Wire mimalloc into codex-cli behind an optional feature so performance-focused local builds can opt in. Co-authored-by: Codex --- MODULE.bazel.lock | 2 ++ codex-rs/Cargo.lock | 20 ++++++++++++++++++++ codex-rs/Cargo.toml | 1 + codex-rs/cli/Cargo.toml | 5 +++++ codex-rs/cli/src/main.rs | 4 ++++ 5 files changed, 32 insertions(+) diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index 3ca1e5e96..a78c03705 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -980,6 +980,7 @@ "libdbus-sys_0.2.7": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cc\",\"optional\":true,\"req\":\"^1.0.78\"},{\"kind\":\"build\",\"name\":\"pkg-config\",\"optional\":true,\"req\":\"^0.3\"}],\"features\":{\"default\":[\"pkg-config\"],\"vendored\":[\"cc\"]}}", "libloading_0.8.9": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1\",\"target\":\"cfg(unix)\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1.1\"},{\"name\":\"windows-link\",\"req\":\"^0.2\",\"target\":\"cfg(windows)\"},{\"features\":[\"Win32_Foundation\"],\"kind\":\"dev\",\"name\":\"windows-sys\",\"req\":\"^0.61\",\"target\":\"cfg(windows)\"}],\"features\":{}}", "libm_0.2.16": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"no-panic\",\"req\":\"^0.1.35\"}],\"features\":{\"arch\":[],\"default\":[\"arch\"],\"force-soft-floats\":[],\"unstable\":[\"unstable-intrinsics\",\"unstable-float\"],\"unstable-float\":[],\"unstable-intrinsics\":[],\"unstable-public-internals\":[]}}", + "libmimalloc-sys_0.1.44": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.0\"},{\"name\":\"cty\",\"optional\":true,\"req\":\"^0.2\"},{\"name\":\"libc\",\"req\":\"^0.2\"}],\"features\":{\"arena\":[],\"debug\":[],\"debug_in_debug\":[],\"extended\":[\"cty\"],\"local_dynamic_tls\":[],\"no_thp\":[],\"override\":[],\"secure\":[],\"v3\":[]}}", "libredox_0.1.12": "{\"dependencies\":[{\"name\":\"bitflags\",\"req\":\"^2\"},{\"name\":\"ioslice\",\"optional\":true,\"req\":\"^0.6\"},{\"name\":\"libc\",\"req\":\"^0.2\"},{\"name\":\"redox_syscall\",\"optional\":true,\"req\":\"^0.7\"}],\"features\":{\"call\":[],\"default\":[\"call\",\"std\",\"redox_syscall\"],\"mkns\":[\"ioslice\"],\"std\":[]}}", "libsqlite3-sys_0.30.1": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"runtime\"],\"kind\":\"build\",\"name\":\"bindgen\",\"optional\":true,\"req\":\"^0.69\"},{\"kind\":\"build\",\"name\":\"cc\",\"optional\":true,\"req\":\"^1.1.6\"},{\"name\":\"openssl-sys\",\"optional\":true,\"req\":\"^0.9.103\"},{\"kind\":\"build\",\"name\":\"pkg-config\",\"optional\":true,\"req\":\"^0.3.19\"},{\"kind\":\"build\",\"name\":\"prettyplease\",\"optional\":true,\"req\":\"^0.2.20\"},{\"default_features\":false,\"kind\":\"build\",\"name\":\"quote\",\"optional\":true,\"req\":\"^1.0.36\"},{\"features\":[\"full\",\"extra-traits\",\"visit-mut\"],\"kind\":\"build\",\"name\":\"syn\",\"optional\":true,\"req\":\"^2.0.72\"},{\"kind\":\"build\",\"name\":\"vcpkg\",\"optional\":true,\"req\":\"^0.2.15\"}],\"features\":{\"buildtime_bindgen\":[\"bindgen\",\"pkg-config\",\"vcpkg\"],\"bundled\":[\"cc\",\"bundled_bindings\"],\"bundled-sqlcipher\":[\"bundled\"],\"bundled-sqlcipher-vendored-openssl\":[\"bundled-sqlcipher\",\"openssl-sys/vendored\"],\"bundled-windows\":[\"cc\",\"bundled_bindings\"],\"bundled_bindings\":[],\"default\":[\"min_sqlite_version_3_14_0\"],\"in_gecko\":[],\"loadable_extension\":[\"prettyplease\",\"quote\",\"syn\"],\"min_sqlite_version_3_14_0\":[\"pkg-config\",\"vcpkg\"],\"preupdate_hook\":[\"buildtime_bindgen\"],\"session\":[\"preupdate_hook\",\"buildtime_bindgen\"],\"sqlcipher\":[],\"unlock_notify\":[],\"wasm32-wasi-vfs\":[],\"with-asan\":[]}}", "libz-sys_1.1.23": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.0.98\"},{\"kind\":\"build\",\"name\":\"cmake\",\"optional\":true,\"req\":\"^0.1.50\"},{\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.43\"},{\"kind\":\"build\",\"name\":\"pkg-config\",\"req\":\"^0.3.9\"},{\"kind\":\"build\",\"name\":\"vcpkg\",\"req\":\"^0.2.11\"}],\"features\":{\"asm\":[],\"default\":[\"libc\",\"stock-zlib\"],\"static\":[],\"stock-zlib\":[],\"zlib-ng\":[\"libc\",\"cmake\"],\"zlib-ng-no-cmake-experimental-community-maintained\":[\"libc\"]}}", @@ -1010,6 +1011,7 @@ "memchr_2.7.6": "{\"dependencies\":[{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.20\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0.3\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"libc\":[],\"logging\":[\"dep:log\"],\"rustc-dep-of-std\":[\"core\"],\"std\":[\"alloc\"],\"use_std\":[\"std\"]}}", "memoffset_0.6.5": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"autocfg\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\"}],\"features\":{\"default\":[],\"unstable_const\":[]}}", "memoffset_0.9.1": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"autocfg\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\"}],\"features\":{\"default\":[],\"unstable_const\":[],\"unstable_offset_of\":[]}}", + "mimalloc_0.1.48": "{\"dependencies\":[{\"default_features\":false,\"name\":\"libmimalloc-sys\",\"req\":\"^0.1.44\"}],\"features\":{\"debug\":[\"libmimalloc-sys/debug\"],\"debug_in_debug\":[\"libmimalloc-sys/debug_in_debug\"],\"default\":[],\"extended\":[\"libmimalloc-sys/extended\"],\"local_dynamic_tls\":[\"libmimalloc-sys/local_dynamic_tls\"],\"no_thp\":[\"libmimalloc-sys/no_thp\"],\"override\":[\"libmimalloc-sys/override\"],\"secure\":[\"libmimalloc-sys/secure\"],\"v3\":[\"libmimalloc-sys/v3\"]}}", "mime_0.3.17": "{\"dependencies\":[],\"features\":{}}", "mime_guess_2.0.5": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3\"},{\"name\":\"mime\",\"req\":\"^0.3\"},{\"name\":\"unicase\",\"req\":\"^2.4.0\"},{\"kind\":\"build\",\"name\":\"unicase\",\"req\":\"^2.4.0\"}],\"features\":{\"default\":[\"rev-mappings\"],\"rev-mappings\":[]}}", "minimal-lexical_0.2.1": "{\"dependencies\":[],\"features\":{\"alloc\":[],\"compact\":[],\"default\":[\"std\"],\"lint\":[],\"nightly\":[],\"std\":[]}}", diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 7a554c2bc..6e40a7cb7 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1659,6 +1659,7 @@ dependencies = [ "codex-utils-cli", "codex-windows-sandbox", "libc", + "mimalloc", "owo-colors", "predicates", "pretty_assertions", @@ -5882,6 +5883,16 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "libmimalloc-sys" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "667f4fec20f29dfc6bc7357c582d91796c169ad7e2fce709468aefeb2c099870" +dependencies = [ + "cc", + "libc", +] + [[package]] name = "libredox" version = "0.1.12" @@ -6163,6 +6174,15 @@ dependencies = [ "autocfg", ] +[[package]] +name = "mimalloc" +version = "0.1.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1ee66a4b64c74f4ef288bcbb9192ad9c3feaad75193129ac8509af543894fd8" +dependencies = [ + "libmimalloc-sys", +] + [[package]] name = "mime" version = "0.3.17" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 556976fc1..e8d36e961 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -244,6 +244,7 @@ log = "0.4" lru = "0.16.3" maplit = "1.0.2" mime_guess = "2.0.5" +mimalloc = "0.1" multimap = "0.10.0" notify = "8.2.0" nucleo = { git = "https://github.com/helix-editor/nucleo.git", rev = "4253de9faabb4e5c6d81d946a5e35a90f87347ee" } diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index 7e703efe1..cc2837739 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -12,6 +12,10 @@ path = "src/main.rs" name = "codex_cli" path = "src/lib.rs" +[features] +default = [] +mimalloc = ["dep:mimalloc"] + [lints] workspace = true @@ -43,6 +47,7 @@ codex-terminal-detection = { workspace = true } codex-tui = { workspace = true } codex-tui-app-server = { workspace = true } libc = { workspace = true } +mimalloc = { workspace = true, optional = true } owo-colors = { workspace = true } regex-lite = { workspace = true } serde_json = { workspace = true } diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index c4dd32bef..e3d625b46 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -42,6 +42,10 @@ mod mcp_cmd; #[cfg(not(windows))] mod wsl_paths; +#[cfg(feature = "mimalloc")] +#[global_allocator] +static GLOBAL_ALLOCATOR: mimalloc::MiMalloc = mimalloc::MiMalloc; + use crate::mcp_cmd::McpCli; use codex_core::config::Config; From 90ee68291f37c95ba0eb4ce35b0826b054b861a4 Mon Sep 17 00:00:00 2001 From: Eric Juta Date: Mon, 30 Mar 2026 23:59:36 +0100 Subject: [PATCH 44/45] build(just): add perf-build-local recipe Add a local PGO-oriented release build recipe for codex-cli with native CPU tuning and optional extra training commands. Co-authored-by: Codex --- justfile | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/justfile b/justfile index 326324199..7a1abd91c 100644 --- a/justfile +++ b/justfile @@ -75,6 +75,44 @@ bazel-remote-test: build-for-release: bazel build //codex-rs/cli:release_binaries --config=remote +# Build a machine-local codex binary with native CPU tuning, panic=abort, +# profile-guided optimization, and mimalloc on top of the existing release +# profile's LTO and codegen-units settings. +# +# You can provide additional representative training commands via: +# CODEX_PGO_TRAIN=' +# ./target/release/codex --version >/dev/null +# ./target/release/codex exec --help >/dev/null +# ' just perf-build-local +perf-build-local: + #!/usr/bin/env bash + set -euo pipefail + PGO_DIR="${TMPDIR:-/tmp}/codex-pgo" + rm -rf "$PGO_DIR" + mkdir -p "$PGO_DIR" + LLVM_PROFDATA="$(command -v llvm-profdata || xcrun --find llvm-profdata)" + COMMON_RUSTFLAGS="-C target-cpu=native" + if command -v ld64.lld >/dev/null 2>&1 || command -v lld >/dev/null 2>&1; then + COMMON_RUSTFLAGS="$COMMON_RUSTFLAGS -C link-arg=-fuse-ld=lld" + fi + CARGO_PROFILE_RELEASE_LTO=fat \ + CARGO_PROFILE_RELEASE_OPT_LEVEL=3 \ + CARGO_PROFILE_RELEASE_CODEGEN_UNITS=1 \ + CARGO_PROFILE_RELEASE_PANIC=abort \ + RUSTFLAGS="$COMMON_RUSTFLAGS -C profile-generate=$PGO_DIR" \ + cargo build -p codex-cli --release --features mimalloc + ./target/release/codex --help >/dev/null + ./target/release/codex exec --help >/dev/null + ./target/release/codex mcp --help >/dev/null + if [ -n "${CODEX_PGO_TRAIN:-}" ]; then sh -lc "$CODEX_PGO_TRAIN"; fi + "$LLVM_PROFDATA" merge -output="$PGO_DIR/merged.profdata" "$PGO_DIR" + CARGO_PROFILE_RELEASE_LTO=fat \ + CARGO_PROFILE_RELEASE_OPT_LEVEL=3 \ + CARGO_PROFILE_RELEASE_CODEGEN_UNITS=1 \ + CARGO_PROFILE_RELEASE_PANIC=abort \ + RUSTFLAGS="$COMMON_RUSTFLAGS -C profile-use=$PGO_DIR/merged.profdata -C llvm-args=-pgo-warn-missing-function" \ + cargo build -p codex-cli --release --features mimalloc + # Run the MCP server mcp-server-run *args: cargo run -p codex-mcp-server -- "$@" From 713d29264e0930e1d4b0b674a990a0fcdd5a6256 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 2 Apr 2026 12:47:07 +0000 Subject: [PATCH 45/45] build(deps): bump dtolnay/rust-toolchain from 1.93.0 to 1.100.0 Bumps [dtolnay/rust-toolchain](https://github.com/dtolnay/rust-toolchain) from 1.93.0 to 1.100.0. - [Release notes](https://github.com/dtolnay/rust-toolchain/releases) - [Commits](https://github.com/dtolnay/rust-toolchain/compare/1.93.0...1.100.0) --- updated-dependencies: - dependency-name: dtolnay/rust-toolchain dependency-version: 1.100.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/rust-ci.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index c203e2b74..abfe28fd7 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -67,7 +67,7 @@ jobs: working-directory: codex-rs steps: - uses: actions/checkout@v6 - - uses: dtolnay/rust-toolchain@1.93.0 + - uses: dtolnay/rust-toolchain@1.100.0 with: components: rustfmt - name: cargo fmt @@ -83,7 +83,7 @@ jobs: working-directory: codex-rs steps: - uses: actions/checkout@v6 - - uses: dtolnay/rust-toolchain@1.93.0 + - uses: dtolnay/rust-toolchain@1.100.0 - uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2 with: tool: cargo-shear @@ -98,7 +98,7 @@ jobs: if: ${{ needs.changed.outputs.argument_comment_lint_package == 'true' || github.event_name == 'push' }} steps: - uses: actions/checkout@v6 - - uses: dtolnay/rust-toolchain@1.93.0 + - uses: dtolnay/rust-toolchain@1.100.0 with: toolchain: nightly-2025-09-18 components: llvm-tools-preview, rustc-dev, rust-src @@ -148,7 +148,7 @@ jobs: run: | sudo DEBIAN_FRONTEND=noninteractive apt-get update sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends pkg-config libcap-dev - - uses: dtolnay/rust-toolchain@1.93.0 + - uses: dtolnay/rust-toolchain@1.100.0 with: toolchain: nightly-2025-09-18 components: llvm-tools-preview, rustc-dev, rust-src @@ -272,7 +272,7 @@ jobs: fi sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends "${packages[@]}" fi - - uses: dtolnay/rust-toolchain@1.93.0 + - uses: dtolnay/rust-toolchain@1.100.0 with: targets: ${{ matrix.target }} components: clippy @@ -615,7 +615,7 @@ jobs: - name: Install DotSlash uses: facebook/install-dotslash@v2 - - uses: dtolnay/rust-toolchain@1.93.0 + - uses: dtolnay/rust-toolchain@1.100.0 with: targets: ${{ matrix.target }}