Skip to content

Latest commit

 

History

History
1489 lines (1333 loc) · 98.5 KB

ARCHITECTURE.md

File metadata and controls

1489 lines (1333 loc) · 98.5 KB

Architecture

This document explains how agentic-api is put together: the crate boundaries, the request lifecycle, and where to make a change for common contribution tasks. It assumes you've read the crate overview in AGENTS.md and complements it — AGENTS.md covers tooling and conventions, this document covers the mental model.

Background on why the system is shaped this way lives in the ADRs (ADR-01, ADR-02, ADR-03) and the design docs under docs/design/. Those documents record proposals and drift over time; this document describes the code as it exists today and will be kept current as the code moves. Where a design doc's "as-built" notes and the code agree, this document just states the outcome.

Workspace layout

agentic-api/
  crates/
    agentic-server-core/   # "agentic_core" — pure Rust orchestration library
    agentic-server/        # axum HTTP/WS gateway + the `agentic` CLI launcher
    agentic-llm-d/         # split-execution state backend for the llm-d coordinator
    agentic-praxis/        # placeholder: future Praxis gateway adapter
  • agentic-server-core (library crate name agentic_core) is where all domain logic lives: request/response types, SSE parsing, the agentic loop, tool framework, and the storage layer. It has no HTTP framework dependency.
  • agentic-server is a thin transport layer: an axum binary that parses HTTP/WS, calls into agentic_core, and streams the result back. It also happens to host a second, unrelated binary — a CLI launcher (agentic) that spawns the gateway and a coding harness (Codex/Claude Code) as subprocesses for local use.
  • agentic-llm-d is a separate axum backend for the llm-d coordinator, which runs inference itself. Its router exposes /v1alpha/responses/hydrate and /v1alpha/responses/persist, plus health/readiness probes. It uses agentic_core for state services and does not proxy or call a model.
  • agentic-praxis is currently a placeholder. Per ADR-03, the intent is for it to wrap each agentic-server-core public function as an HttpFilter so Praxis can compose the agentic loop declaratively instead of going through agentic-server's axum router. Nothing is implemented there yet.

The dependency direction is one-way: agentic-server and agentic-llm-d depend on agentic-server-core, never the reverse. agentic-server-core has no axum dependency; client-facing HTTP/WS transport belongs to the adapter crates, while upstream HTTP/SSE I/O lives in core inference transport.

Request flow at a glance

Client ──HTTP/WS──▶ agentic-server (handler/*)
                        │
                        ▼
              agentic_core::executor::ExecuteRequest::run()
                        │
          ┌─────────────┼──────────────────────────┐
          ▼             ▼                           ▼
     rehydrate()   upstream call(s) + tool loop   persist()
   (storage read)  (vLLM, gateway tool execution)  (storage write)
                        │
                        ▼
                 SSE / JSON back to client

Persistence uses sqlx against a driver-agnostic Any pool backed by SQLite or Postgres (storage::pool). The upstream inference call targets vLLM's own stateless Responses API — this project owns the state, vLLM owns tokenization and generation (see ADR-01 §1.1).

One Responses turn may contain several inference rounds, but it is exposed and persisted as one response. The single-agent path is:

rehydrate history
      │
      ▼
create AgentPipeline + prepare tool-search state
      │
      ▼
build EngineOrchestration (ToolRegistry + response budget)
      │
      ▼
┌─▶ optional compaction ─▶ one upstream inference round
│                              │
│                              ▼
│                    AgentPipeline ingests JSON or SSE
│                    and relays public stream events
│                              │
│                              ▼
│                    resolve calls by ownership
│                       │               │
│                       │               └─ client-owned calls stay unresolved
│                       ▼
│             GatewayScheduler plans and executes calls
│             with bounded fan-out and ordered results/events
│                       │
│                       ▼
│                  classify_round
│              │            │             │
│              │            │             └─ client/incomplete/done: finalize
│              │            └─ append calls/results to continuation input
└──────────────┘  (`Continue`, at most 10 rounds)
      │
      ▼
finalize one public response + persist one turn

A mixed round may contain both ownership classes. Gateway-owned calls still execute and are recorded, while the response returns the unresolved client-owned calls for the client to resolve. Streaming uses the same round loop and projects it through one continuous SSE lifecycle. With multi_agent.enabled: true, a response-level coordinator schedules separate agent contexts through the same inference, tool, and delivery boundaries; see HTTP multi-agent execution.

agentic-server — the transport layer

Two binaries sharing one library

The crate produces a library plus two independent binaries. src/lib.rs exports agentic_cli, agentic_harness, agentic_output, agentic_process, app, auth, handler, and model_capabilities. On top of that:

Binary Entry point Uses
agentic-server (the gateway) src/main.rs app, auth, handler, model_capabilities, plus binary-private server.rs and config_file.rs
agentic (the CLI launcher) src/bin/agentic.rs agentic_cli, agentic_harness, agentic_output, agentic_process, model_capabilities

These are two unrelated concerns bundled in one crate. If you're working on request handling, ignore agentic_cli*/agentic_harness.rs/agentic_output.rs/ agentic_process.rs entirely — they're the launcher that spawns the gateway binary and a coding harness (Codex or Claude Code) as subprocesses for local, single-command use (agentic serve <model>), and never touch the request path.

model_capabilities.rs is the one deliberate exception: it is the shared contract both sides speak. The gateway resolves each model's InputModalities there and serves them in the Codex catalog; the launcher parses that same catalog back through CodexCatalogCapabilities before writing an isolated Codex home. Keeping one definition is what stops the HTTP catalog and a launcher catalog from disagreeing about image support.

app.rs, server.rs, main.rs

  • app.rs (library) builds the router: AppState (the per-request-shared state: exec_ctx: Arc<ExecutionContext>, proxy state, readiness/websocket trackers, config) and build_router_with_auth(state, server_config, authenticator), which wires every route and optionally layers OIDC auth (auth::require_oidc) onto the protected ones.
  • server.rs (binary-private) owns process lifecycle: build_state (constructs ExecutionContext::from_config, i.e. where the DB pool actually gets created), serve_gateway/serve_gateway_until_signal (bind, serve, graceful shutdown with a bounded drain), and run/run_with_llm (standalone mode, optionally spawning a vLLM subprocess).
  • main.rs (binary-private) is the clap CLI front end: parses config from flags/env/config.toml, then calls server::run or server::run_with_llm.

HTTP handlers (handler/http/)

Route Handler File
POST /v1/responses responses handler/http/responses.rs
GET /v1/responses/{response_id} retrieve_response handler/http/responses.rs
POST /v1/responses/compact compact_response handler/http/responses.rs
POST /v1/conversations create_conversation handler/http/conversations.rs
GET/POST/DELETE /v1/conversations/{id} Conversation CRUD handler/http/conversations.rs
POST/GET /v1/conversations/{id}/items Batch append and ordered listing handler/http/conversation_items.rs
GET/DELETE /v1/conversations/{id}/items/{item_id} Item retrieval and removal handler/http/conversation_items.rs
POST /v1/messages messages handler/http/messages.rs
POST /v1/messages/count_tokens count_tokens handler/http/messages.rs
GET /v1/models models handler/http/models.rs
GET /health health handler/http/models.rs
GET /ready ready handler/http/models.rs

Handlers make a request-scoped decision between two paths:

  • Stateful/executor path — used when the request needs state (store: true, previous_response_id, conversation_id, compaction, or a gateway-owned tool). Builds an ExecuteRequest (Responses) or calls run_messages_loop/ run_messages_stream (Messages) against state.exec_ctx.
  • Pass-through path — everything else is forwarded to vLLM unchanged via agentic_core::proxy, with no state, no persistence.

The HTTP Responses handler first parses RequestPayload<RawValue> so routing fields remain typed while provider-specific text formats stay opaque on the pass-through path. Executor routes convert that raw field to ResponseTextConfig before building ExecuteRequest, preserving strict validation for in-process execution.

WebSocket transport (handler/websocket/)

GET /v1/responses upgrades to a WebSocket. Structurally this is not a one-shot handler like the HTTP routes — responses_ws_loop is a long-lived session loop that decodes typed create and injection messages and uses ExecuteRequest::run_retained() to drive the same inference/tool loop and persistence APIs as HTTP. Requests with distinct stream_id values run concurrently, while requests in the same lane remain FIFO; requests without a stream_id share a default FIFO lane. The session admits at most 64 active or queued requests and 12 MiB of aggregate request data. WebSocket sessions force stream: true and honor the requested store value. Because axum's built-in graceful shutdown doesn't wait for upgraded connections, AppState carries a separate WebSocketTracker so shutdown can drain in-flight sessions. connection.rs owns socket reading, writing, and shutdown; multiplexer.rs owns bounded create admission and lane FIFO; control.rs owns retained response routes and injection tasks.

WebSocket session admission allows multi-agent configuration with store: true, including configuration inherited from a stored agent tree. The common multi-agent validation still runs before and after restoring effective settings. Generating responses register connection-local routes before their first event is relayed. Injection bypasses create-lane FIFO and submits typed batches to the core RunControl; only the coordinator validates call ownership and mutates agent histories.

types/websocket.rs owns the flat create/inject client envelope; create flattens the shared RequestPayload and adds generate and stream_id. Injection accepts typed function, shell, custom-tool, and discovery outputs. OpenAI duplex recordings establish live function, shell, and discovery acceptance and preservation of custom outputs in late rejections. Core validates output kind and call ownership. Live discovery reuses the existing tool-search preparation and registry builder before the owning agent's next round, preserving its inference-round budget. Unknown and retained-completed targets return response.inject.failed with the documented response_not_found and response_already_completed codes and unchanged typed input. Malformed injection schema closes the connection after a generic validation error. An already-resolved call instead returns response.inject.failed with invalid_input and the submitted input, leaving the connection usable, as observed in the OpenAI capture. Paired active recordings confirm atomic rejection of duplicate-within-batch and mixed valid/unknown-call batches, successful valid-only resubmission, and live duplicate rejection. Deterministic core and transport tests exercise the same decisions. A quiescent tree with pending client calls completes and persists its checkpoint for continuation rather than waiting indefinitely for socket input. Commands losing the finalization race return their input after commit.

The connection owns control and relay JoinSets, at most 32 admitted controls with 12 MiB of aggregate serialized input, and 128 response routes. Completed routes expire after 60 seconds or are evicted under capacity pressure; an admitted task pins its route through acknowledgement relay. Control exhaustion closes the connection, with an overload error when the bounded outbound queue can admit it. There is no overflow buffer. ResponseEventSink retains the response's sequence owner and one-entry delivery queue after execution. Accepted decisions enqueue acknowledgements through that same sink; a typed flush barrier keeps terminal delivery ahead of the next create in its lane without carrying dummy frame data or advancing the public sequence. An independent socket writer drains the shared 64-entry queue with a ten-second send deadline, so the reader never waits for a socket write. Failed acknowledgement delivery is a transport failure, never an instruction to retry accepted input. Disconnect cancels and joins response owners and admitted controls. Graceful shutdown drains ordinary active creates and cancels live multi-agent runs, which could otherwise wait forever for new client input after admission stops. Admitted acknowledgements retain their delivery leases. Non-generating prewarm creates use local completion and apply continuation input through the core's agent ownership validation before committing the updated tree, without inference.

OpenAI/gateway duplex recordings and deterministic tests cover the workflows and active edge cases above. Full replay of independently recorded model and hosted-tool dependencies and comparative resource measurements remain follow-up work; this implementation does not advertise complete transport conformance.

Executor streams propagate downstream backpressure through a bounded event channel. [responses] configures separate ceilings for upstream JSON bodies, upstream SSE lines, retained response data, and client stream events. Wire ceilings default to 16 MiB each. Each request shares an 8 MiB retained-data budget across MCP discovery, upstream rounds, and normalized gateway tool output. Ingestion charges logical retained items rather than repeated SSE envelopes, so changing delta size does not change the budget for the same output. These are logical limits, not total process memory bounds. MCP discovery participates in the same 16-permit materialization window as gateway calls and is capped at 64 server declarations and 128 discovered tools per request. The WebSocket transport queues only serialized, size-checked events, capped at the configured max_stream_event_bytes including routing metadata, in its 64-entry outbound queue. handler/websocket/responses/event.rs owns that envelope and its limit. The executor receives the effective event ceiling after routing overhead and checks its terminal event before persistence or session checkpoint publication. Local completion validates both lifecycle events before persistence. A slow connection can retain up to 64 times the configured event ceiling in its outbound queue, in addition to executor queues, parsed upstream payloads, and active response state. Authentication is rechecked at request dispatch so queued work cannot start after identity expiry. Errors are modeled by a dedicated WsError enum (handler/websocket/error.rs) rather than reusing the HTTP JSON-error path, since some failure modes (a dead socket) must not attempt to write a response.

Opt-in core continuation sessions (executor/session.rs)

Core callers can use ExecuteRequest::with_session or rehydrate_in_session to retain response state without durable storage. The WebSocket multiplexer owns one ResponseSessionGroup per connection and keeps one session per lane, including idle lanes. No-session HTTP and split execution keep their existing behavior.

The connection retains at most 128 lanes (including the default lane), 32,768 items and 16 MiB per checkpoint, and 32 MiB of aggregate serialized checkpoints. These are retention ceilings, not measured process-memory bounds. New lanes receive an immediate 429 once the lifetime lane limit is reached. Request-count and request-byte overloads also return immediate 429 responses without mutating retained state, so rejected work cannot invalidate an earlier accepted queued continuation. Other validation errors execute in lane order and evict only a matching referenced parent when routing is valid. Parent lookup happens when execution begins; accepted queues do not reserve parent snapshots. Existing fork and execution-failure eviction rules still apply. Disconnect aborts and joins request tasks, waits for active leases to release pinned state, and drops the entire connection group before the close handshake.

A ResponseSession owns one latest canonical checkpoint and one execution slot. The executor pins a parent before inference and publishes completed or incomplete state before exposing terminal completion. Failed continuations discard only a referenced checkpoint owned by that session. Dropping the owner closes the session and rejects late publication. Callers still cancel and join active work explicitly; wait_until_idle waits for the execution lease to end, but does not cancel it.

ResponseSessionGroup allows independent serial members to find and pin each other's latest checkpoints. Failed forks cannot evict the source member's state. The group bounds lifetime member count, each checkpoint's items and serialized bytes, and aggregate retained bytes. Shared parent references count once; replaced parents still pinned by active work and prepared checkpoints awaiting persistence remain charged until their last reference is released. Reservation happens before durable writes, and failure or cancellation returns unused capacity. Replacement requires room for both old and new snapshots until publication. These retention budgets are not a bound on temporary allocations, execution copies or process memory. Scheduling, FIFO queues and active-work limits remain caller concerns.

Response-scoped session history records reasoning, messages and calls in inference-round order, then built-in call outputs. Public output is accumulated separately and is not appended to retained history twice. Compaction replaces the canonical window while preserving MCP discovery records needed for orchestration. Durable restoration and replayed compaction input select the effective compacted window before validating current calls; obsolete stored rows are not deleted or charged to that retained window.

For response-scoped session execution, store: false has no durable writes or database fallback. A stored child of a transient parent persists the complete canonical window without creating a row or dangling database reference for that parent. Explicit conversation_id requests keep the existing durable Conversations policy and append output only through the conversation handler. Their session lease still serializes execution without recording a second copy. Core commit supports prewarming without inference; WebSocket generate: false uses that same session rehydration and commit path.

handler/common.rs

Transport helpers shared by the HTTP and WS handlers: body reading with a shared size cap, generic JSON parsing, bearer-token extraction, SSE response wrapping, and rendering an ExecutorError as a JSON error body.

auth.rs

OIDC bearer-token authentication: discovery, JWKS fetch/cache/refresh, and the require_oidc axum middleware layered onto protected routes in app.rs. The WebSocket handler also reads the resulting AuthenticatedPrincipal extension directly, to detect token expiry mid-session.

The hard boundary: no direct storage access

Nothing in agentic-server's request-handling code (handler/*, app.rs, server.rs) imports agentic_core::storage directly. All persistence goes through AppState.exec_ctx: Arc<ExecutionContext> — e.g. ExecuteRequest::run(), create_conversation(), persist_turn(), rehydrate_conversation(), ExecutionContext::storage_ready(). ExecutionContext::from_config is the only place that constructs the storage handlers, and it does so precisely so callers don't need to depend on the storage layer:

let conv_handler = ConversationHandler::new(ConversationStore::new(pool.clone()));
let resp_handler = ResponseHandler::new(ResponseStore::new(pool.clone()));

Two narrow, deliberate exceptions: the agentic validate CLI subcommand (src/bin/agentic.rs) calls storage::create_pool_with_schema directly as a connectivity pre-flight check, outside the request path; and integration tests / benches under crates/agentic-server/{tests,benches} import ConversationStore/ ResponseStore directly for fixture setup and assertions. Production request-handling code should never do either.

agentic-server-core — the orchestration core

Per AGENTS.md, the internal dependency direction is: types/ owns wire/domain data → events/ parses upstream events → tool/ owns tool discovery, routing, and execution → executor/ orchestrates across inference, tools, and storage → storage/ owns persistence. Handlers call executor APIs; the executor coordinates events, tool, and storage; those share contracts through types.

In src/ code, reuse utils::common for JSON serialization/deserialization and fallback behavior. Do not call serde_json directly when an existing strict, optional, or defaulting helper expresses the required policy; add a focused helper there when the policy is reused. Direct serde_json use is fine in tests, fixtures, and cassette tooling. Keep Serde wire-format attributes on the owning type.

Prefer established Rust patterns

Before adding a conversion helper, wrapper API, or state abstraction, search the owning types and the standard Rust traits for an existing pattern. Use the standard conversion traits and consult the Rust Cookbook for established recipes for common operations. Prefer code whose ownership, failure mode, and lifecycle are visible in its types.

  • Implement From<T> for U for an infallible, obvious, and value-preserving consuming conversion. This provides Into<U> automatically; implement From rather than implementing Into directly.
  • Implement TryFrom<T> for U when validation or conversion can fail. This provides TryInto<U> automatically and keeps the error type with the destination contract.
  • Use AsRef, borrowed accessors, or typed views for cheap borrowed conversions that should not consume or clone the value.
  • Use iterator adapters such as map, filter_map, flat_map, and collect for collection conversion. Keep the per-item conversion on the item type instead of duplicating a variant match in each caller.
  • Use newtypes to establish validated identities, indexes, names, and framed values at construction. OutputIndex, NonEmptyToolName, and SseLine are examples in this codebase.
  • Use enums and exhaustive matches for closed state machines, Option/Result combinators for explicit absence and failure, and RAII guards for cleanup or cancellation tied to ownership.

Search types/io/ before introducing a helper named convert_*, into_*, or append_*. Existing examples include TryFrom<&EventPayload> for typed output items, From implementations between wire/domain types, and OutputItem::to_input_item for the deliberately optional continuation conversion. An inherent conversion method is appropriate when the operation has domain policy that the standard trait cannot express clearly, as with output variants that are intentionally omitted from continuation input.

types/ — wire shapes, not behavior

This module's job is JSON ⇄ Rust type conversion and shape validation for the Responses and Messages APIs. It is not where tool execution, state transitions, or DB access happen — those live in tool/, executor/, and storage/ respectively.

  • types/request_response.rs — RequestPayload is the deserialized incoming request. Its to_upstream_request(&self, stream: bool) -> Result<UpstreamRequest<'_>, ToolError> is the seam between the OpenAI-shaped request and vLLM's contract. It: flattens Codex namespace tool members to model-visible names (into the tool layer's ToolDeclarations), validates every declared tool (ToolDeclaration::validate()), and normalizes each supported model-visible tool to UpstreamTool::Function (ToolDeclaration::to_function_tools()). File search and unknown typed declarations normalize to no upstream tool; a code interpreter declaration normalizes to a fixed function contract. The server request path checks runtime availability before it calls this conversion. Every declaration that does reach vLLM is type: "function", because that's the only tool type it speaks. The conversion also resolves/validates tool_choice and applies ResponsesInput::model_input(). It's called from executor/upstream.rs's fetch_blocking_payload and fetch_stream_payload — the two functions that actually build the outbound request to vLLM.
  • types/io/ — input.rs (inbound message/tool-call/tool-result shapes, ResponsesInput), output.rs (outbound output items: messages, function calls, web search/MCP calls, reasoning — plus the ApplyDone trait described below), tools.rs (the normalized FunctionTool and ToolChoice, distinct from tool declarations), reasoning.rs (typed reasoning content, summaries, item status, and bounded opaque state), and usage.rs (token accounting structs). ResponsesInput::model_input() is the final model-visibility boundary used by RequestPayload::to_upstream_request: it removes orchestration-only McpListTools, CompactionTrigger, and public collaboration input items. A persisted Compaction item is different: the latest checkpoint supersedes earlier model context and is converted into an assistant output_text summary, while canonical retained user messages and items after the checkpoint remain. This keeps rich continuation state available to orchestration without sending unsupported public item types to vLLM.
  • types/io/multi_agent.rs — public collaboration output items and agent-message content. Its input module defines replay forms with optional IDs; output IDs remain required. Agent-message content includes text, images, files, refusals and encrypted parts, preserving response content during replay. These wire types do not impose a storage policy; stored-only execution is a gateway admission constraint.
  • types/tools/params.rs — the tool declaration shapes a client sends: ResponsesTool (tagged enum: Function, ToolSearch, Mcp, WebSearch, FileSearch, CodeInterpreter, Namespace, Custom, Unknown) and each variant's param struct. This is a good concrete example of the module boundary: ResponsesTool is defined here as a pure wire shape; the Responses adapter converts it into the tool layer's ToolDeclaration (tool/declaration.rs, responses_declarations), and the declaration's behavior — validate() and to_function_tools() — is implemented on that one internal type in tool/normalize.rs, which delegates to per-type handlers. Types own the shape; tool owns what it means.
  • types/messages/ — a separate, parallel type layer for the Anthropic Messages API (MessagesRequest, ContentBlock, etc.). tool_seam.rs is the pure, I/O-free adapter for tool_use/tool_result blocks and the gateway-ownership map; the declarations themselves are mapped by tool::registry_tools into the tool layer's ToolDeclaration, so both APIs build a ToolRegistry through one path without the Messages loop depending on RequestPayload/ResponsePayload.
  • types/event.rs — small status enums (ResponseStatus, MessageStatus).

Output-to-input conversion for continuation rounds

types/io/output.rs::OutputItem::to_input_item is the single semantic conversion from a response output item to the input item seen by a later inference round. Code that continues a response must use this method; it must not serialize an OutputItem as input or introduce another match over OutputItem variants.

current round Vec<OutputItem>
        │
        ▼
filter_map(OutputItem::to_input_item)   ← the conversion policy
        │
        ▼
ResponsesInput                         ← append only
        │
        ▼
ResponsesInput::model_input()          ← final upstream-visibility filter

There are two adapters around that one policy:

  • executor/gateway.rs::append_output_items_to_input calls OutputItem::to_input_item and appends the result to the current request before the next round. Its handling of ResponsesInput::Text versus ResponsesInput::Items is container mutation, not a second conversion policy.
  • storage/types/item.rs::InOutItem::into_input_items uses the same method when rebuilding continuation input from persisted history. Already stored InputItems pass through unchanged.

Messages, reasoning, function calls, custom calls, tool-search calls, compaction items, and MCP list metadata have explicit continuation representations. Public web_search_call and mcp_call output items return None: gateway execution records the canonical model-facing function call and its function_call_output separately, so converting the public projection would duplicate the call or lose result details. Gateway tool results are already InputItems and are appended through append_tool_outputs; they do not need an output-to-input conversion.

Native upstream code_interpreter_call output items convert to typed continuation input. A gateway-executed code-interpreter call is marked internally and omitted by the same conversion because its model-facing function call and output are retained separately; storage preserves that origin outside the public wire shape.

events/ — parsing upstream SSE, and how to add a new event type

This module normalizes raw upstream SSE lines into typed frames, decoupled from the executor so the accumulator doesn't do inline JSON parsing.

  • types.rs — SSEEventType (the wire event's type, covering both OpenAI's and vLLM's naming, e.g. response.done vs response.completed), EventPayload (the typed, extracted payload — falls back to Raw(Value) for events not deeply parsed yet), WireEvent (the raw pass-through shape, used for re-serialization), EventFrame { event_type, payload, wire } (the normalized output), SSEItemType (output-item kind: reasoning, function call, MCP call, etc.).
  • sse.rs — SseLine and ClassifiedSseLine. SseLine::parse performs only field-level SSE classification (Data, Done, or Ignore) and accepts the optional space after data:. Its redacted Debug implementation reports only payload size.
  • normalize.rs — normalize_sse_data_checked parses one classified data payload into an EventFrame while preserving invalid-output-index errors for the ingestion policy. extract_payload dispatches to small per-event extract_* helpers. The public normalize_sse_line remains a convenience adapter for callers that do not need policy-aware errors.

To add support for a new SSE event, the touch points are, in order:

  1. events/types.rs — add the SSEEventType variant, its wire-string mapping both directions, and (if it carries structured data) an EventPayload variant.
  2. events/normalize.rs — extend extract_payload and add an extract_* helper if the payload needs real parsing (otherwise it can fall through to Raw).
  3. Extend executor/accumulator/'s typed transition matches. Streaming callers enter through RoundIngestion::push; do not create a caller-specific validator or folding path.
  4. If the event represents a client-executed function shape, extend the corresponding translator under executor/translate/. If it is gateway-synthesized, construct the typed EventFrame in executor/gateway.rs and present it with StreamRelay::emit_local.

executor/ — the loop, and the server's only door into storage

This is the layer agentic-server talks to. It owns the request lifecycle: rehydrate, call inference, run the tool loop, persist. agentic-server never reaches past it.

  • request.rs — RequestContext (per-turn state: original + enriched request, response/conversation IDs) and ExecutionContext (long-lived deps: storage handlers, HTTP client, gateway tool executors, LLM base URL). ExecutionContext is what AppState holds; it exposes conv_handler/resp_handler (the modes/ handlers below), never the raw stores.
  • rehydrate.rs — rehydrate_conversation() loads prior history from either the conversation store or the response store depending on which ID the request carries, and builds the enriched RequestContext. Rehydration retains internal InputItem::McpListTools records so ToolRegistry can suppress repeated MCP discovery lifecycle output. After stored history and the new request input are combined, pending_calls.rs validates the complete continuation's function/custom call sequence. Every call and call output must have a non-empty call_id; call IDs must be unique across the sequence; and each output must resolve exactly one currently pending call of the same item kind. An output without a pending call, a second output for an already resolved call, or a function/custom kind mismatch is an invalid request rather than evidence that the call was resolved. Valid unresolved calls remain ordered by their original emission, and the first unresolved client-executed call produces the existing missing-output error. Gateway-executed built-in tool calls are resolved and recorded within their originating round, so they do not remain pending at this boundary.
  • upstream.rs — the narrow adapter between inference transport and the pipeline. It builds UpstreamRequests, snapshots registry classification facts into an owned TranslationContext, passes the shared retained-data budget into ingestion, and passes each live JSON or SSE body to AgentPipeline. It does not charge SSE wire bytes.
  • inference.rs — call_inference(): the raw HTTP/SSE transport to vLLM. No parsing beyond splitting data: ... lines and stopping at [DONE].
  • pipeline.rs, pipeline/ — AgentPipeline, the request-owned entry point for both response body formats. RoundIngestion owns the synchronous per-round semantic core; its StreamRelay (relay.rs, relay/) owns awaited, ordered client delivery across rounds.
  • engine.rs — the top-level orchestrator: ExecuteRequest/execute(), create_conversation(), and EngineOrchestration, which owns the response byte budget, accumulated output/usage, and root AgentTurn. It advances the turn and applies response completion policy to its typed RoundResult/RoundDecision. The current single-agent path supplies MAX_GATEWAY_TOOL_ROUNDS = 10 as a per-turn limit, not a future tree-wide collaboration limit. Also home to run_compaction_trigger, run_blocking, and run_stream (owns and polls the loop alongside its bounded event receiver, forwards events as SSE, and persists before yielding the terminal event). engine/streaming.rs owns cancellation, failure delivery, and terminal validation before persistence; engine/streaming/producer.rs drives the stream-owned orchestration future without spawning a nested producer task.
  • engine/agent_turn.rs — execution for one agent: AgentTurn owns the tool registry and round counter and exclusively borrows its AgentPipeline. The response adapter retains the pipeline so error handling can recover its context and delivery state. run_round performs compaction, inference and built-in tool execution using the engine's shared budget, records canonical call/output history, and reports a typed outcome. It does not spawn agents or finalize/persist a public response.
  • multi_agent.rs — scoped task ownership for engine-owned tree coordination. RunOwner bounds root/descendant tasks, supports interruption, and joins teardown. multi_agent/registry.rs holds parentage, current turn identity, logical phases and bounded canonical mailboxes. It reserves bytes through the shared response budget before mutation and settles turns with exactly one parent notification. Registry operations are synchronous; the coordinator must reconcile/join work before settlement. multi_agent/pending_calls.rs retains each client call's originating agent, turn and kind. It validates output batches before mutation, retains accepted outputs until transfer to canonical context, and keeps resolved IDs for duplicate detection. Acceptance by this table does not itself resume an agent. It complements the existing single-history rehydration validator; neither public error mapping nor transport-specific batch policy is defined by it. multi_agent/control.rs provides opt-in live input through RunControl::channel and ExecuteRequest::with_run_control. It requires a stored, streaming multi-agent inference request. Nonblocking admission bounds each batch's logical retained bytes and at most 32 outstanding commands, including unread decisions. Adapters must keep draining response events while awaiting decisions. The coordinator validates the whole batch, transfers outputs into canonical histories, invalidates stale compaction generations, and resumes a waiting owner only after all of its calls are resolved. Interrupted owners retain input without being restarted. Without a control endpoint, HTTP execution still completes at the client-output boundary. Finalization closes admission and rejects queued commands synchronously. Finalizing is distinct from successful persistence; a lost reply is an execution failure with unknown acceptance, never permission to retry silently. This core seam does not yet own WebSocket routing, wire acknowledgements, or completed-response retention; those belong to the connection adapter and retained event sink described above. engine/multi_agent.rs coordinates these components for stored HTTP responses. Canonical histories and pending calls remain owned by the coordinator; the pipeline never spawns subagents. Its context, actions, rounds, delivery, and compaction modules separate restoration, collaboration commands, scheduling, public output and explicit root compaction. The run owner cancels and joins tasks when a run finishes or its retained owner cancels it; dropping an HTTP stream aborts them without joining. Interruption takes effect at the current round boundary. The contracts are described below.
  • persist.rs — persist_response/persist_turn, which apply the request's storage policy (should_persist: a no-session store: false turn is not written) and route to ConversationHandler or ResponseHandler in modes/ depending on whether the turn is conversation-scoped or response-scoped.
  • compaction.rs — compact_response() (the explicit /v1/responses/compact path) and maybe_compact_context() (automatic, threshold-triggered, called from the round loop before each inference call).
  • modes/conversation.rs, modes/response.rs — ConversationHandler and ResponseHandler. Thin, 1:1 wrappers around storage::ConversationStore / storage::ResponseStore that translate RequestContext into store calls and StorageError into ExecutorError. This is the sanctioned boundary between the executor and the storage stores — nothing above this layer touches storage::conversation/storage::response directly. Today they only cover what the pipeline and retrieval need (get, get_or_create, create, rehydrate[_snapshot], execute_turn, commit_tree, retrieve, validate_exists); any new CRUD operation beyond persist/rehydrate belongs here, added as a new method that delegates to the corresponding store.
  • error.rs — ExecutorError, with the mapping methods (http_status(), error_type(), into_response_body(), ...) handlers use to render errors.

HTTP multi-agent execution

multi_agent.enabled selects gateway orchestration; it is not forwarded to the upstream model. Enabled requests require store: true; the current validator also rejects max_tool_calls, reasoning.summary, and reasoning.generate_summary. These are gateway validation rules, not a claim that every upstream provider has the same restrictions. New requests without multi-agent configuration use single-agent execution; continuations of a stored agent tree inherit its configuration.

EngineOrchestration — one public response and persistence boundary
└─ MultiAgentRun — tree, admission, mailboxes, canonical histories, completion policy
   ├─ RunOwner — scoped round/compaction tasks, cancellation and joining
   └─ Per-agent execution
      ├─ AgentPipeline — context, tool-search state and round ingestion
      └─ AgentTurn / AgentExecutionState — registry, round progress and run_round

Parallel tool execution runs multiple calls within one agent's inference round. Multi-agent execution gives each agent its own history, assignment, tool registry, loaded tool definitions and round progress. The model requests collaboration through canonical function tools; actions.rs handles spawning, messages, follow-up tasks, waiting, interruption and listing. max_concurrent_subagents limits active descendant turns across the whole tree, excluding the root; it does not fix the total number of agents or collaboration calls in a response. Finished turns release capacity. guidance.rs adds current identity, assignment, direct-child states and available slots to each model round without storing that guidance in canonical history.

The coordinator alone mutates canonical agent state. Workers receive snapshots and return typed outcomes. AgentRegistry records logical phases and mailbox contents; waiting agents resume on mail or a wait deadline, without holding an inference task open. Child completion notifies its parent once. A root final answer settles only the root's turn: descendants continue until they finish or pause for client tool outputs. Mail to an idle root is retained without restarting it. Root completion does not implicitly interrupt descendants; interruption remains an explicit collaboration action. The run shares a retained-data budget and accumulates inference/compaction usage, while separate retention, round and runtime limits bound work. Teardown cancels and joins scoped tasks before the terminal response decision is exposed.

Client tool continuations. Function, local shell, custom and tool-search calls can suspend an agent for client execution. PendingClientCalls records each call ID, item kind and owning agent turn outside the replaceable history prefix. A continuation must supply outputs for every outstanding call; the batch is validated before any agent history changes. Each accepted output goes only to its owner and resumes that agent. A parent parked waiting for children stays asleep until mailbox delivery wakes it; a new user message resumes the root.

Tool-search outputs restore discovered definitions into the requesting agent's ToolSearchState before its next registry and upstream request are built. Namespace and custom declarations still use the existing canonical function normalization and public-shape translators. MCP, web search and operator-enabled code interpreter execute in the gateway and append their canonical call/output pairs during the round, so they require no client output continuation. Tool search retains its normal parallel_tool_calls validation; enabling multi-agent execution does not bypass that rule.

Code interpreter uses the same canonical function/tool loop in every agent. Agents share the ready executor and its process-wide guest permits through ExecutionContext; max_concurrent_subagents does not increase interpreter capacity. Its public code_interpreter_call items and streaming events are attributed to the executing agent and use the response-wide output indexes. Canonical call/output pairs survive stored tree continuation, so submitting a sibling's client tool output does not execute completed Python calls again. Feature, operator and readiness gates apply before inference, including when the tool declaration is inherited from storage.

Compaction. Automatic compaction is per agent. prepare_agent supplies a default compaction threshold of 100,000 estimated tokens when none is configured. A CompactionPlan owns a resolved-prefix snapshot, agent identity and context generation. Its completion replaces that prefix only when the generation still matches (CompactionCommit::Applied); a stale result leaves current history intact. The coordinator accounts for completed compaction work once even when the result is stale. Pending calls, unresolved suffixes and new mail are not discarded by prefix replacement.

An explicit compaction_trigger in a Responses request uses MultiAgentRun::compact_root: it restores the tree, applies supplied continuation input and compacts the root's resolved prefix while preserving child histories and pending calls. No agent tasks run during this operation. The resulting tree passes through the same validation and persistence boundary as a normal multi-agent response. This is distinct from the standalone /v1/responses/compact endpoint.

Persistence and public output. A versioned StoredTreeSnapshot contains agent histories, phases, mailboxes, loaded tools and pending client calls. ValidatedTreeCheckpoint checks its limits, identities and references before live state is constructed or committed. persist.rs calls the existing mode handler's commit_tree; transport handlers do not write storage directly, and tree persistence uses the existing response/conversation stores. ResponseMetadata.multi_agent_tree restores execution state, while response_snapshot retains the exact terminal public response for GET retrieval. Continuation restoration discards the latter duplicate but retains the tree.

The public transcript contains attributed multi_agent_call (mac_), multi_agent_call_output (maco_) and agent_message (amsg_) items alongside each agent's reasoning, messages and tool calls. Public collaboration items are not replayed as model input; the coordinator retains canonical function calls, outputs and delivered mail in private agent histories. ResponsesInput::model_input removes the public collaboration items at the upstream boundary.

Both JSON and SSE use this coordinator. Streaming workers feed a bounded, acknowledged channel into the existing response-wide StreamRelay. AgentRoundId scopes source indexes; delivery's public-position mapping is shared with final output assembly. The coordinator emits one response lifecycle and completes items after canonical registration and public projection. Delivery does not become a second response assembler. An item that agent frames already presented needs only output_item.done. An item the coordinator creates itself, which no upstream frame presented, gets its whole public lifecycle from item_lifecycle.rs. That module builds each frame lazily from the completed item and decides per item kind, through an exhaustive match, what streams between output_item.added and output_item.done. The relay attributes and presents those frames.

Recorded contract checks. tests/multi_agent_contract_test.rs loads independently recorded OpenAI and gateway YAML for review, proposals, mixed tools and client-executed tools in both streaming and non-streaming modes. The typed comparison helper checks call/agent relationships, lifecycle consistency and required tool kinds; the client scenario also checks tool-search, discovered functions and custom tool continuations. It permits model-dependent names, text, identifiers and action counts rather than requiring byte-for-byte output equality. Unit tests use synthetic values; cassette capture remains in the Python recorder and scenario shell script.

Responses pipeline and ownership boundaries

RFC #241 and issue #243 established one path for JSON and live SSE response bodies:

inference.rs
  ├─complete JSON───────────────────────┐
  └─framed SSE lines─▶ SseLine::parse───┤
                                        ▼
                                  AgentPipeline
                         │
                         ▼
                   RoundIngestion
                         │
                         ▼
              ResponseAccumulator
              (typed response state)
                         │ validated frame + typed call
                         ▼
              TranslationDispatcher
                (per-tool translators)
                         │ translated SSE frames
                         ▼
                   StreamRelay
                         │
                         ▼
              GatewayStreamAccumulator
                         │
                         ▼
                       client

EngineOrchestration surrounds the pipeline:
inference rounds → gateway execution → terminal policy → persistence
Stage Responsibility
Inference transport (inference.rs) HTTP I/O, byte chunks, SSE framing, timeouts, and [DONE] detection.
Event parsing (events/) Classify one SSE line and normalize one data payload into an EventFrame.
Request pipeline (pipeline.rs) Hold one request context, tool-search state, the response's stream relay, and the JSON/SSE body entry points.
Round ingestion (pipeline/ingest.rs) Process one body with one ResponseAccumulator, one TranslationDispatcher, and final response normalization.
Stream relay (relay.rs) Own the client sink, presentation state, round offset, deferred buffer, and their limits; present local and upstream frames through one send path.
Agent execution (engine/agent_turn.rs) Own the per-agent registry and round progress; perform inference/tool work and return typed round outcomes.
Orchestration (engine.rs) Own agent execution, the shared response budget, response assembly, terminal policy, and persistence coordination.

AgentPipeline currently lives for the complete public response. EngineOrchestration creates the shared response budget and an AgentTurn that retains its own registry and exclusively borrows the pipeline. Each turn step asks upstream.rs to run the inference body through run_with_json_body or live run_with_stream_body. A new RoundIngestion is created for every body and consumed by finalization, while StreamRelay and its GatewayStreamAccumulator survive across inference rounds.

For multi-agent execution, the engine owns tree coordination and schedules separate agent execution contexts. Per-agent ingestion feeds typed frames through a bounded, acknowledged channel into one response-wide StreamRelay. Its source-scoped index mapping is shared with the engine's final response assembly; delivery does not assemble items.

The live runner polls one framed line, performs synchronous ingestion and translation, then awaits delivery before polling the next line. This propagates bounded sender backpressure to the upstream body. Ingestion remains inline; moving it to a worker is the benchmark decision tracked by #245.

The boundary contract is one owner and one path per concern:

  • Strict and lenient validation use the same typed accumulator transitions; the policy selects validation/disposition rules and end-of-stream behavior.
  • Output-item lifecycle state is scoped to one inference round and keyed by validated output_index. Item ID and kind must match on later events; active and completed slots remain distinct so index reuse and duplicate completion are rejected.
  • Translation consumes validated EventFrames plus the accumulator's typed function call view and produces the public wire lifecycle for client-owned tools.
  • Delivery consumes translated frames and provides ordered, awaited client emission across inference rounds.

accumulator/ — typed response assembly

ResponseAccumulator owns validation, response lifecycle, typed output slots, delta folding, terminal error/incomplete state, usage, and final ResponsePayload assembly. slot.rs owns active/completed lifecycle and identity bindings, identity.rs extracts semantic identities, and json.rs contains strict JSON response-shape validation. active.rs dispatches exhaustively to per-kind state; active_text.rs owns message parts and reasoning text/summary accounting. Both JSON and SSE ultimately use the same finalization state.

Shared response.content_part.* events identify an item by output index and item ID, not by content type. Normalization parses a typed completed part; the existing slot resolves whether it belongs to a message or an agent message. Each owner's state validates the allowed content, duplicate completion and consistency with the final item.

Output items are constructed through their TryFrom<&EventPayload> implementations in types/io/output.rs. Active slots fold deltas in place and use the type's ApplyDone implementation when its completion event arrives. For an already parsed output-item completion, the private MergeDone implementations in accumulator/completion.rs merge the concrete item with its retained fields and buffers. slot.rs dispatches to those implementations after validating identity and lifecycle. Finalization promotes each completed typed item once and preserves validated output-index order.

The pipeline's streaming entry is process_line(ClassifiedSseLine). It normalizes and validates a data line, applies the event to the slot keyed by its validated output index, and returns at most one validated EventFrame for translation. For function events, accumulated_function_call(output_index) exposes a borrowed typed call and its folded arguments. finish applies SSE end-of-stream policy; finalize preserves the status loaded from a complete JSON body.

When adding an output-item kind, extend its typed construction and completion logic in types/io/output.rs, the variants and exhaustive dispatch in accumulator/active.rs, and completed-item measurement in executor/response_budget.rs.

Retained-byte accounting stays in synchronous ingestion. response_budget.rs defines one comprehensive RetainedSize measurement and RetainedAccount for charging growth and reconciling completed items. Every unbounded collection entry has a structural charge, including empty JSON values and web-search queries; unrestricted string fields such as message role and message content type count by length. Bounded enums, including reasoning content kinds and item status, need no variable charge. Reasoning text and summary parts each charge a container plus text bytes; opaque state charges its decoded string bytes. Delta text and new part containers are charged before growth. Completion uses the existing ApplyDone/MergeDone policy and measures its effect; reasoning text/summary completion measures only the inserted part and its corresponding streamed counter; shell command completion measures only its command and current buffer. Both avoid rescanning preceding parts or static nested metadata. Full measurement is reserved for item completion and final reconciliation. slot.rs charges supplied identities before index insertion or late binding; IDs shared by a typed item and its indexes count once logically, including pending kinds with no typed snapshot yet. accumulator/details.rs charges retained terminal errors and incomplete reasons under the same JSON/SSE measurement. Charges are cumulative without refunds. A rejected completion may temporarily allocate its single bounded wire payload before ingestion drops the failed round.

translate/ — tool-specific public-shape translation

TranslationDispatcher is a synchronous inline dispatcher. It owns per-call translation state, classifies each validated function call from an owned TranslationContext, and routes client-executed tools to their specific ToolTranslator implementation:

  • FunctionHandler → FunctionTranslator
  • CustomHandler → CustomTranslator
  • ShellHandler → ShellTranslator
  • CodexNamespaceHandler → CodexNamespaceTranslator
  • ToolSearchHandler → ToolSearchTranslator

This is the reverse side of upstream canonicalization. The model emits the canonical function_call shape. For a client-owned tool, its associated translator restores the public Responses wire item and its SSE lifecycle (output_item.added, type-specific delta/done events, and output_item.done). The ordinary function translator preserves the function-call shape; custom, namespace, and tool-search translators restore their specific public forms.

The private HasTranslator association lives in the executor so tool handlers do not depend on SSE or executor types. TranslationContext is an owned snapshot of the registry facts and request metadata needed for classification and restoration. It provides each round's tool classification and owns final restoration of public tool declarations, custom/namespace shapes, tool choice, and tool-search metadata.

Gateway-executed types (Mcp, WebSearch, FileSearch, and CodeInterpreter) are classified as gateway calls by the dispatcher, which suppresses their canonical upstream function-call lifecycle. gateway.rs owns their execution result mapping and synthesizes their public item lifecycle because the gateway knows when execution starts, completes, or fails. GatewayStreamAccumulator then assigns cross-round sequence numbers, rebases output indexes, and deduplicates response start events. Events that arrive before a function name is known are buffered with a 256 KiB total byte limit and replayed when the call resolves.

Shell functions are restored to shell_call and command SSE events for client execution. Shell is not gateway-executed: ShellHandler deliberately does not implement GatewayExecutor, so the gateway never runs shell commands on the model's behalf. PR #264 removed the optional shell executor to keep execution on the client; RFC #352 proposes a gateway-executed shell backend as future work. The client executes each call and submits a shell_call_output item with the same call_id.

relay.rs and gateway_accumulator.rs — continuous client SSE

StreamRelay, owned by AgentPipeline for the whole response, is the only owner of the client sink, cross-round presentation state, the current round's output offset, the deferred-frame buffer, and RelayLimits. Callers state a frame's origin by the method they call; none of them passes a sender, accumulator, or offset:

Method Frames Transforms
accept One translation from ingestion Withhold terminal response events for the engine, defer or present each frame, then release what a moved defer window no longer hides.
emit_upstream One translated provider frame Restore the public response IDs and rebase the output index onto the round.
emit_local Gateway-synthesized and multi-agent frames None: they already carry public IDs and absolute indexes.
release_deferred Deferred provider frames that a Release admits As emit_upstream.

begin_round sets the round's offset and rejects a new round while the previous round still holds deferred frames. Every method reaches the client through one private send, the only place an output offset is applied. The relay's constructor fixes its sink: StreamRelay::client for the bounded SSE channel, response for a retained WebSocket response's ResponseEventSink, agent for a multi-agent round's AgentFrameSink, and detached for collect-only and JSON execution. AgentPipeline::new takes the configured relay, so the pipeline never handles a sender. Sequence and response-start deduplication state is committed after the sink accepts the event, so a failed serialization, closed receiver, or cancelled send cannot consume it. Enqueueing is not client receipt or playback acknowledgement, and this does not make an interrupted pipeline resumable or support retrying an already rebased frame.

The relay defers provider frames at and after the first hidden gateway-call index, and index-less frames while that window is open. The buffer keeps release order on entry: indexed frames by output index, then index-less frames, each in arrival order. RelayLimits bounds it by 1024 entries and 256 KiB of serialized wire data by default. A single frame over the byte limit is rejected on its own terms, and no limit drops a frame. A frame's charged size is refunded only when the sink accepts it, so a cancelled or failed release keeps the remaining frames and their byte account. These limits are separate from response assembly, retained session state, and total process memory. A slow client applies backpressure through the bounded sink and never moves frames into the deferred buffer.

release_deferred is the one release path. It runs when the defer window moves during a stream; after gateway execution, once per output item, so each item's deferred frames follow its synthesized gateway lifecycle; and, when an upstream round fails, before the terminal event without executing gateway tools.

The Responses BoxStream owns its orchestration future directly. It polls that future and the existing bounded event receiver on the consumer's task; there is no detached producer, asynchronous abort reaper, or producer JoinHandle. When the consumer stops polling, the orchestration future stops with it. Dropping an unpolled or active stream synchronously drops its producer, active per-request tool futures, and continuation lease. A multi-agent run is the exception inside that future: its rounds are tasks owned by RunOwner. They progress only until the bounded frame channel is full, and dropping the stream drops RunOwner, which aborts them without joining; each stops at its next await point and its result is discarded. Remote services and shared HTTP/MCP connection drivers still have their own lifetimes; dropping a local future does not undo external side effects.

Producer completion and panic both dispose of the producer future before draining accepted events in order and exposing one outcome. Panic isolation uses catch_unwind and a typed, static client error; it does not alter the process panic hook. On success, the engine still validates terminal size and persists/publishes the checkpoint before yielding completion. Cancellation during storage waits drops the pending persistence future through the same owner. WebSocket responses run through the retained owner in engine/retained.rs, which spawns execution and cancels and joins it explicitly; a multi-agent run cancels and joins its rounds through the coordinator's cancellation arm. Their wait_until_idle lease fences remain in place.

GatewayStreamAccumulator carries only cross-round presentation state: monotonic sequence_numbers, public output_index rebasing, and deduplication of response start events. It holds no OutputItem assembly state and no sink. Sending is awaited before ingestion continues, so sender closure or delivery failure propagates through the live pipeline. After the producer finishes, engine/streaming.rs takes the state back from the relay and renders the terminal event on a clone, which it discards if persistence fails; cloning copies a few scalars.

gateway.rs — the tool-loop's building blocks

The round-by-round loop belongs to engine.rs::EngineOrchestration. gateway.rs supplies the planning, execution, projection, and continuation helpers it calls each round:

  • GatewayScheduler::plan creates one slot per gateway-owned function call. Each slot owns the original item index, public output index, typed GatewayBinding, and lifecycle projection; a missing executor is represented by an explicit slot rather than omitted from a parallel vector. GatewayScheduler::execute_with_budget then returns one ordered GatewayCallResult per slot. futures::future::join_all polls all planned calls, while a tokio::sync::Semaphore created by each execute_with_budget invocation limits active tool executions in that round using tools.max_concurrent_gateway_calls (default 5, configurable through AGENTIC_MAX_CONCURRENT_GATEWAY_CALLS). This nonzero setting is carried by the owning ExecutionContext into each scheduler; the permits are local to the round, not a process-wide or cross-request limit. Completion may occur out of order, but join_all preserves model call order in the collected results. The permit limit bounds execution, not the number of planned call futures waiting for permits.
  • A normalized web_search function call may batch at most five queries. The JSON Schema advertises the ceiling and the handler enforces it again because normalized web search currently uses non-strict arguments. Provider searches acquire a shared handler semaphore initialized from tools.max_concurrent_gateway_calls, preventing batched calls from multiplying the configured outbound concurrency. Results remain collected in query order for the public web_search_call.action.queries projection.
  • Each bound call first acquires its optional same-tool exclusion permit, then a round execution permit, then a materialization permit shared by cloned scheduler policies (also used by MCP discovery, with a limit of 16). Waiting for same-tool exclusion does not consume a round permit; waiting for materialization does. The independent 60-second timeout wraps GatewayBinding::execute only after all permits are acquired. These permit waits do not count toward it, so it is not a deadline for the entire round or total call latency. Timeout, execution, and tool-config failures become failed tool outputs that can be fed back to the model instead of failing the whole response. A tool registered as gateway-owned without an implementation (currently file search) likewise produces an error tool result.
  • Parallel safety is a per-handler contract. GatewayExecutor::supports_parallel_execution defaults to false; registration turns that into a GatewayBinding::self_exclusion semaphore. The semaphore serializes only simultaneous calls to the same model-visible tool name. It never blocks different tools from running concurrently. MCP and web search opt into same-tool parallel execution.
  • max_tool_calls admission lives in gateway/admission.rs. AgentTurn owns one BuiltInToolCallBudget per response, and GatewayScheduler::plan_with_budget admits gateway-executed calls in output order before any call runs, so concurrency cannot exceed the limit. A call over the limit is a refused slot: it never reaches its handler, opens no agentic.tool.execute span, and returns the limit message as its tool call output. The first refused call with a public item keeps its started item (web_search_call at searching, code_interpreter_call at interpreting); refused MCP calls and later refusals have no public item, and public_item_index keeps later public indexes contiguous for deferred frames. After a refusal, AgentPipeline withholds gateway-executed tools from later upstream requests without changing the persisted enriched_request.tools.
  • Each scheduler slot retains its GatewayEventPlan; gateway/lifecycle.rs's emit_gateway_start_events and emit_gateway_completed_events synthesize public lifecycle events for gateway-executed web search, MCP, and optional code-interpreter calls from those same slots. A code-interpreter call emits output_item.added, code_interpreter_call.in_progress, the code_interpreter_call_code.delta/done pair, code_interpreter_call.interpreting, code_interpreter_call.completed, and output_item.done, with indexes from the plan and sequence numbers from StreamRelay. The dispatcher suppresses the canonical upstream function_call lifecycle after classifying the call as gateway-executed. Native upstream code_interpreter_call items instead follow the accumulator's typed lifecycle and pass through; contradictory item kinds at one output index are handled by ingestion before translation. The ordinary path emits all planned start events, executes the round concurrently, then emits ordered completed/failed events.
  • Streaming may receive client-visible output interleaved with gateway calls. In that case engine/agent_turn.rs::execute_round_output executes the same GatewayScheduler concurrently, then gateway/lifecycle.rs::relay_round_events walks the output items in order: each item's synthetic gateway lifecycle is presented, followed by that item's deferred upstream frames through StreamRelay::release_deferred with Release::Item, which presents them at the item's public_item_index. Concurrency and wire ordering are therefore separate concerns.
  • public_output_items is the public projection: custom function calls become custom_tool_call; gateway-owned internal function calls become their handler's web_search_call/mcp_call output; client-owned function calls remain function calls. The original gateway function calls and function_call_output results are retained separately for continuation persistence.

The round decision remains in engine.rs, after gateway execution:

Decision Condition and state transition
RequiresClientAction At least one client-owned call exists. Any gateway calls from the mixed round have already executed; their internal calls/results are recorded before returning.
Done No gateway result and no client-owned call remains. Finalize accumulated output and usage.
Continue Gateway calls ran and round budget remains. Append the upstream output plus gateway results, set tool_choice: auto, and infer again.
Incomplete Gateway calls ran on the tenth round. Record the final calls/results and return status: incomplete instead of leaving a dangling call.

parallel_tool_calls is an upstream model-generation preference, not a gateway scheduler switch. It is forwarded to vLLM for all supported declaration mixtures and defaults to false when omitted. Whatever calls the model emits are executed under the per-round execution permit limit and each handler's same-tool safety policy.

messages_context.rs / messages_loop.rs / messages_request.rs / messages_stream.rs / messages_tools.rs

A parallel, independent implementation of the same shape of loop for the Anthropic Messages API. messages_stream.rs's own header comment describes it as "structurally the Anthropic-native analogue of GatewayStreamAccumulator, kept deliberately parallel for a future consolidation" — it never touches RequestPayload/ResponsePayload/ AgentPipeline/ResponseAccumulator/GatewayStreamAccumulator/TranslationDispatcher, operating directly on Anthropic-shaped JSON. The two loops share only the protocol-neutral pieces: ToolRegistry::dispatch and types::messages::tool_seam. The round/timeout constants (MAX_GATEWAY_TOOL_ROUNDS, GATEWAY_TOOL_TIMEOUT) are duplicated and manually kept in sync with the Responses-side ones rather than shared — a known seam, not an oversight, per the future-consolidation note.

The JSON and SSE loops read a model turn differently but dispatch its gateway calls through one function, messages_tools.rs's execute_gateway_calls, so admission, execution, and the fed-back tool_results cannot differ between them. Calls are admitted sequentially in model order before any of them starts, then run concurrently under the per-call timeout.

A native web_search_20250305 declaration's max_uses is a request-wide budget of searches, not of calls. The normalized tool sent upstream lets one call batch several queries, so a call is charged for every query the handler would run, counted by the handler's own argument parser. A call the remaining budget cannot cover is refused whole with an error tool_result and leaves the budget untouched, so a later call that fits still runs. A call whose arguments cannot be parsed performs no search and is not charged.

A native web_fetch_20250910 declaration is handled the same way (tool/web_fetch, #408): the upstream sees an ordinary web_fetch function tool with a single url argument, and the gateway executes the call. The Messages adapter (messages_request.rs) judges the Anthropic-specific settings — tool version, citations, cache settings, allowed_callers, max_uses — reads the shared parameters into WebFetchToolParam, and routes them through WebFetchHandler::validate and normalize; the registry seam reads a declaration through the same parser. GatewayExecutors::require_web_fetch is the one owner of the operator switch, used by registry construction and by count_tokens alike. Ownership is request-scoped and derived from the registry (messages_tools::request_gateway_map): the web_fetch name is gateway-owned only when the request-scoped registry bound it to the gateway executor, which only a native declaration does, so a client function that happens to be named web_fetch stays client-owned. The fetch budget is separate from the search budget and counted in fetches; every admitted call is charged whatever its outcome, and a call whose arguments carry no URL is not. Two rules live in the loop rather than the handler because only the loop holds the conversation: the budget, and the Anthropic rule that a URL must already appear in a user message or a tool_result (including hidden web_search results) before it can be fetched. The handler owns everything else — URL admission, the address policy and DNS pinning applied to every redirect hop, domain filtering, HTML-to-text extraction, the content limit, and the max_concurrent_gateway_calls ceiling on fetches in flight — and answers documented failures in the web_fetch_tool_result_error shape as a ToolOutput with a failure status, which the loop reports as is_error without reading the output. Retrieval sits behind the crate-private WebFetchBackend trait; the built-in HTTP backend is the default.

Both loops take a MessagesRequestContext (messages_context.rs), the per-request type that replaced a bare serde_json::Value at that boundary. It holds two views of one request: typed fields for reading tools/stream/model, the current MessagesToolChoice, and the raw JSON body that is actually forwarded upstream. The raw body is deliberately not re-serialized from the typed view — ContentBlock catches unmodeled block types in #[serde(other)] Unknown and models only the fields the gateway reads, so a typed round-trip would drop cache_control and is_error and collapse image/ redacted_thinking into {"type":"unknown"}. The context owns every mutation the loops make to that body (force_stream, append_round) and the native web-search budget, so the two views cannot drift apart uncontrolled; messages and system are reachable only through the raw body, never the typed view.

append_round also changes a fulfilled forced tool_choice (any, or tool matching a returned gateway call result) to auto. The first round retains the client's selector; later rounds can answer from the tool result or choose another tool. Parallel-use settings and extension fields remain intact. Rounds containing client-executed function tools return before this mutation, and Messages does not persist this state.

MessagesToolChoice models auto, any, tool with a non-empty name, and none as an exhaustive enum. It retains parallel-use settings and flattened extension fields. The context performs fulfillment checks and transitions using that enum, then serializes the changed selector into the raw body. Malformed gateway-tool requests return HTTP 400 before inference; a parse failure cannot bypass validation by falling back to the proxy. Requests without gateway tools retain the transparent proxy contract and upstream validation.

vLLM can label a completed, explicitly named tool call end_turn. The shared request context accepts that stop only when the selected gateway tool appears in the round. Streaming additionally requires message_stop; client-executed function tools and truncated rounds remain terminal. A completed client-executed tool_use is surfaced with public stop_reason: tool_use, correcting vLLM's end_turn in JSON and the final SSE message_delta. Mixed rounds hide gateway calls and await the client's output. Token limits, other stop reasons and streams without a completed round keep their original terminal semantics.

Hide-the-call covers every terminal round, not only a mixed one. A round can end while a gateway tool_use is present whenever the stop reason is not a tool-call stop — a max_tokens truncation mid-call, or an end_turn the context does not accept — and that call is never executed. messages_loop.rs's deliver strips gateway-owned tool_use blocks from the returned message, matching the streaming accumulator, which suppresses them on every round. The assistant turn fed back to the model still carries the call.

Each round's usage is folded into messages_usage.rs's MessagesUsageTotals, so when hidden gateway rounds ran, the returned message (JSON) and the terminal message_delta (SSE) report the saturating sum of the Anthropic token counters across every round rather than the final round alone. A streamed round counts its message_start.usage overlaid by its message_delta.usage. Counters no round reported stay absent, the remaining usage fields pass through from the final round, a single-round turn is returned unchanged, and a final round that omits usage still reports the hidden rounds' counters.

storage/ — persistence

  • pool.rs — DbPool = sqlx::Pool<sqlx::Any>, driver-agnostic across SQLite and Postgres. create_pool/create_pool_with_schema and friends build and tune it (WAL mode + busy-timeout retry on SQLite, statement/lock timeouts on Postgres).

  • backend.rs — DatabaseBackend (Postgres/Sqlite/Other) detection from a connection URL, plus URL redaction for safe logging.

  • schema.rs — migrations and readiness (PoolWithSchema::ensure_schema_ready), including a path for a supervisor-managed schema that skips running migrations itself and just verifies compatibility. Readiness probes live in schema/readiness.rs.

  • conversation/api.rs — tenant-scoped conversation and item CRUD. Item ownership is checked through the conversation so Responses-persisted items are visible too. Item writes share lock_in_tx with turn persistence; a monotonic conversation revision makes deletions invalidate active snapshots. Removal detaches items rather than destroying history referenced by stored responses.

  • models/ — raw sqlx::FromRow row structs per table (Conversation, Item, Response) plus their raw, transaction-aware SQL functions (create_in_tx, get, lock_in_tx, ...). This is the literal DB row shape: JSON columns are still strings here.

  • types/ — the conversion layer from those raw rows into business types, via From/TryFrom impls: ConversationData/ConversationSnapshot, ResponseData/ ResponseMetadata (parses the JSON metadata column into a typed struct, including an optional terminal ResponsePayload snapshot for GET retrieval and a versioned multi-agent tree checkpoint for continuation), InOutItem (parses an Item.data JSON blob back into a typed InputItem or OutputItem), and StorageError. Store rehydration uses TryFrom<&Item> and fails if a row cannot be decoded; response rehydration also rejects missing referenced rows. It must not silently omit malformed reasoning from a continuation. TryFrom<Response> rejects malformed history references and effective metadata; only SQL NULL keeps the legacy empty/default behavior. Versioned conversation metadata lookup also rejects a missing or foreign captured response. Storage errors omit parser diagnostics that could echo sensitive persisted fields. InOutItem::into_input_items turns a full history into the Vec<InputItem> used for continuation processing: stored InputItems pass through, while stored OutputItems go through OutputItem::to_input_item(). Messages, reasoning, function/custom calls, compaction checkpoints, and MCP list-tools records are retained. Public web_search_call and mcp_call outputs are deliberately omitted because their model-facing function calls and results are already persisted as input items; reconstructing them here would duplicate and lose information from that canonical pair.

    This conversion is not the model visibility boundary. The resulting enriched history still contains InputItem::Compaction, InputItem::CompactionTrigger, and InputItem::McpListTools for executor/registry decisions. Immediately before an upstream request, RequestPayload::to_upstream_request calls ResponsesInput::model_input(): the latest compaction checkpoint is converted to an assistant summary and supersedes older context, while compaction triggers and MCP list-tools records and public collaboration items are removed. In particular, MCP list-tools remains available long enough for the registry to remember which server labels have already been listed, but it is never serialized to vLLM.

  • conversation.rs, response.rs — ConversationStore and ResponseStore: the CRUD-with-transactions layer (create, get, get_or_create, rehydrate[_snapshot], persist/persist_if_version — each transactional, via pool.begin() / tx.commit()). These are not to be called outside executor/ and storage/ themselves. The only sanctioned callers are executor/modes/conversation.rs and executor/modes/response.rs, described above. (Integration tests and benches import them directly for fixtures — that's expected and fine; production code paths should not.)

Stored Responses snapshots are written in the same transaction as response history. Retrieval goes through ResponseHandler::retrieve, independently of upstream availability. Continuation checkpoints omit the snapshot to avoid retaining a duplicate response; they continue to use canonical history and effective settings. Legacy history-only records remain usable for continuation, but GET retrieval reports a conflict rather than fabricating status, usage, or output.

tool/ — the tool framework

Wire shapes for tool declarations live in types::tools (see above); this module owns the behavioral layer — routing, handler traits, normalization, and execution.

Every supported tool, whether client-owned or gateway-owned, implements ToolHandler. That trait is the authority for validating a public declaration and normalizing it to the fixed FunctionTool format understood by the upstream model. The outbound path is:

public ResponsesTool declaration (read as a ToolDeclaration)
        │
        ▼
RequestPayload::to_upstream_request
        │
        ├─▶ ToolDeclaration::validate ──────────▶ ToolHandler::validate
        └─▶ ToolDeclaration::to_function_tools ─▶ ToolHandler::normalize
                                                     │
                                                     ▼
                                      canonical UpstreamTool::Function

RequestPayload::to_upstream_request is the only request-level seam that prepares tools for vLLM. New callers must use it rather than rebuilding function schemas or normalizing declarations in the executor. Declared placeholders that are not yet supported, currently file search, produce no upstream function declaration until they have a complete handler and execution path. Code interpreter has a fixed normalization contract in every build. Requests that declare it fail before inference unless the binary includes the embedded-code-interpreter feature, the operator enables it, and Eryx runtime readiness succeeds.

Component Responsibility
ToolHandler Validate one supported public tool declaration and normalize it into one or more canonical model-visible FunctionTools.
ToolRegistry Provide the request's read-only name lookup for all available tools, including tool type, client/gateway ownership, server label, and any typed gateway binding.
Client ToolTranslator Convert a validated canonical function call back to that client-owned tool's public output shape and SSE lifecycle.
GatewayExecutor and gateway.rs Execute gateway-owned calls and map their start, completion, failure, result, and public output lifecycle.
GatewayStreamAccumulator Project gateway and upstream lifecycle frames into one continuous, correctly indexed and sequenced client stream.
  • declaration.rs — the protocol-neutral ToolDeclaration, the one concrete type the registry and the declaration helpers accept, and each API's conversion into it at its adapter boundary: responses_declarations (and From<ResponsesTool>) for the Responses wire enum, registry_tools for the Messages ToolParam blocks. What MCP discovery records on a declaration is propagated back into a Responses request's wire tools by record_discovered_mcp_tools, explicitly, where the request needs it for upstream use and persistence.

  • normalize.rs — validate(), tool_type(), and to_function_tools() on ToolDeclaration. These are the declaration-level validation and normalization entry points used by RequestPayload::to_upstream_request on the converted declarations. Each supported variant's policy belongs to its corresponding ToolHandler: FunctionHandler, ToolSearchHandler, McpHandler, WebSearchHandler, WebFetchHandler, CodexNamespaceHandler, CustomHandler, or CodeInterpreterHandler. Web search's fixed canonical builder is shared with WebSearchHandler::normalize; it remains one schema even though it has no per-declaration normalization state. The method name is plural because namespace and MCP declarations may expand to several model-visible function tools. FileSearch remains an unsupported placeholder and normalizes to nothing. The code interpreter normalizes to one fixed function contract in every build, while request validation rejects unavailable runtimes before upstream inference. Feature-enabled builds bind the ready private CodeInterpreterExecutor to the provider.

  • handler.rs — the two traits every tool type reasons about:

    pub trait ToolHandler: Send + Sync {
        type ToolParams: Send + Sync;
    
        fn tool_type(&self) -> ToolType;
        fn validate(&self, params: &Self::ToolParams) -> Result<(), ToolError>;
        fn normalize(&self, params: &Self::ToolParams) -> Vec<FunctionTool>;
    }
    
    pub trait GatewayExecutor: ToolHandler + 'static {
        type ExecutionParams: Clone + Send + Sync + 'static;
    
        fn execute(
            &self,
            call_id: &str,
            tool_name: &str,
            arguments: &str,
            params: &Self::ExecutionParams,
        )
            -> Pin<Box<dyn Future<Output = Result<ToolOutput, ToolError>> + Send + '_>>;
        fn supports_parallel_execution(&self) -> bool;
        fn plan_gateway_events(
            &self,
            call: &FunctionToolCall,
            params: &Self::ExecutionParams,
        ) -> GatewayToolEventPlan;
        fn public_output(
            &self,
            call: &FunctionToolCall,
            output: &ToolOutput,
            status: GatewayCallStatus,
            params: &Self::ExecutionParams,
        ) -> Option<OutputItem>;
    }

    GatewayExecutor requires ToolHandler: every executable gateway handler supports typed validation and normalization, but not every ToolHandler is gateway-executable. This inheritance is the ownership invariant: supported client and gateway tools share the same declaration contract before execution ownership matters. ToolParams describes the public declaration; ExecutionParams describes one model-visible executable entry. They intentionally differ for MCP: an McpToolParam declares a server, while an McpDiscoveredToolParam identifies one tool returned by that server. Gateway-owned registry types may also lack an executor entirely. The trait owns three runtime hooks: supports_parallel_execution() controls same-tool self-exclusion, plan_gateway_events() creates the typed public lifecycle projection, and public_output() shapes the completed/failed client-visible item.

    • Client-owned tools implement ToolHandler and have a translator association: see function.rs (FunctionHandler), custom.rs (CustomHandler), codex.rs (CodexNamespaceHandler), and tool_search.rs (ToolSearchHandler). Their calls are returned for the client to resolve; the gateway does not execute them.
    • Gateway-owned / built-in tools implement both traits: see web_search/mod.rs (WebSearchHandler, backed by the configured WebSearchProvider in web_search/you.rs, web_search/brave.rs, web_search/tavily.rs, or web_search/searxng.rs) and mcp/handler.rs (McpHandler, backed by mcp/client.rs's MCP protocol client and mcp/pool.rs's connection pool). web_fetch/mod.rs (WebFetchHandler, Messages-only, backed by a WebFetchBackend — the built-in web_fetch/http.rs fetcher — with web_fetch/policy.rs for URL and address admission, web_fetch/extract.rs for HTML-to-text extraction, and the shared domain_policy.rs for the domain lists both web tools declare) follows the same pattern. They have no client translator association because the gateway owns their execution and public lifecycle.
  • ownership.rs — ToolOwnership::Client versus ToolOwnership::Gateway(Option<GatewayBinding>). A GatewayBinding combines the resolved executor, its typed ExecutionParams, and the optional same-tool semaphore derived from its parallel-safety declaration. A generic adapter checks the executor/parameter pair at construction and erases only that valid bound pair for heterogeneous registry storage. The scheduler therefore never handles untyped JSON configuration or downcasts. Keeping ownership explicit avoids inferring execution policy from whether a handler happens to be present.

  • registry.rs — ToolRegistry, a request-scoped map from model-visible tool name to ToolEntry { tool_type, server_label, ownership }. Its responsibility is to keep the read-only catalog of every available model-visible tool for the request, including declared client tools, namespace members, built-ins, and discovered MCP tools. A lookup answers which tool type a name identifies and whether its ownership is Client or Gateway; a gateway entry may also carry its typed GatewayBinding. Its constructor,

    pub async fn build_with_handlers(
        tools: &mut [ToolDeclaration],
        executors: &mut GatewayExecutors,
    ) -> Result<Self, ToolError>

    is the stable entry point every caller uses to build a registry for a request — Responses passes the declarations responses_declarations converted from its ResponsesTools and records discovery back with record_discovered_mcp_tools, Messages the ToolDeclarations from registry_tools — its signature should not change. It resolves namespace members, inserts one entry per declared/discovered tool, and for Mcp/WebSearch pulls the actual executor from GatewayExecutors (discovering live MCP tools via tools/list in the process). ToolRegistry::dispatch(call) is the per-call routing method the Messages loop uses; the Responses GatewayScheduler resolves the same binding into one call plan so execution, self-exclusion, item position, and lifecycle hooks cannot drift apart. After construction, consumers query this catalog for classification, ownership, and gateway bindings. upstream.rs snapshots its classification facts into a per-round TranslationContext, which translators use when restoring client-owned tool calls.

    MCP discovery history is also request-scoped registry state: mcp_list_tools_items: HashMap<String, Vec<McpListTools>> groups records by server_label. Registry construction puts the current discovery item first; rehydration appends prior InputItem::McpListTools records only for labels already present in that map. mcp_list_tool_items() exposes entries whose vector still has exactly one element—the current item with no history—to both blocking output assembly and streaming lifecycle emission. Streaming clears the map after the first inference round. Consequently a server's list lifecycle is emitted only when no prior list record exists and never repeats across rounds.

  • executors.rs — GatewayExecutors, a shared registry built once at startup and reused across requests. Configured MCP servers are the only lazily connected slot: clients and discovered handlers are created on demand and cached by server_label across requests; request-declared servers connect afresh for each request, and their discovered handlers are reused only within that request. The shared WebSearchHandler and the opt-in CodeInterpreterExecutor are built once at startup; the code interpreter is backed by an embedded Eryx sandbox, enabled by configuration, and the default config is disabled. These executors bind into the existing gateway scheduler, not a second tool loop. Client-owned tools (function, custom, shell, namespace) never touch this file; their registry entries are inserted with ToolOwnership::Client and no GatewayExecutors involvement. Shell is not gateway-executed: ShellHandler deliberately does not implement GatewayExecutor, so declaring a shell tool never grants the gateway permission to execute arbitrary commands (PR #264 removed the optional executor; RFC #352 proposes a gateway-executed shell backend as future work).

Shell item history is preserved publicly in storage. At the inference boundary, stored shell calls lower into shell function calls through the standard output-to-input conversion, and typed input conversions lower submitted shell calls and outputs into their function-history forms. Call and output share one call_id, just as declarations and explicit shell selectors are normalized to their function representations. The client execution contract is described in docs/design/shell-tool.md.

To add a new tool type:

  1. Implement ToolHandler, including its typed ToolParams, for it.
  2. If it's gateway-executed, also declare typed GatewayExecutor::ExecutionParams and implement execute — see web_search/mod.rs/mcp/handler.rs.
  3. Wire it into tool/normalize.rs's validate/to_function_tools match arms.
  4. Wire it into tool/registry.rs's build_with_handlers (an insert_*_entry call).
  5. For a client-executed function shape, add its ToolTranslator and associate the handler in executor/translate/client.rs. Gateway-executed tools do not receive a translator association; their public events come from gateway lifecycle plans.
  6. If it needs lazy per-request connection setup, add a slot to GatewayExecutors in tool/executors.rs and reference it from the registry's match arm for that type.

agentic-praxis

Currently a placeholder (src/lib.rs is a comment describing intent). Per ADR-03, this crate will eventually provide HttpFilter implementations, one per agentic-server-core public function, composed into a Praxis filter chain with branch support for tool-call looping — an alternative orchestrator to agentic-server's axum router, reusing the same core logic in-process.

Quick reference: "I want to..."

Task Where
Add a new HTTP or WebSocket route agentic-server/src/handler/{http,websocket}/, wire it in app.rs's build_router_with_auth
Support a new upstream SSE event events/types.rs → events/normalize.rs → executor/accumulator/ → executor/translate/ when the event needs public tool-shape translation
Add a new tool type tool/handler.rs impl(s) → tool/declaration.rs (a ToolDeclaration kind and its view) → tool/normalize.rs → tool/registry.rs → executor/translate/client.rs for client-executed function shapes → tool/executors.rs for lazy gateway setup
Change gateway-round concurrency or lifecycle ordering executor/gateway.rs (GatewayScheduler/event plans) + executor/engine.rs (round decision/ordered streaming) + tool/ownership.rs (typed binding and same-tool safety)
Change agent admission, mailbox wakeups or client continuation routing executor/engine/multi_agent/ + executor/multi_agent/; keep tree persistence in the existing mode handlers
Change client streaming order, buffering, or backpressure executor/relay.rs and executor/relay/; keep parsing in events/ and response assembly in executor/accumulator/
Move streaming ingestion to a worker Benchmark the equivalent inline and worker paths under #245 before changing executor placement
Feed response output into the next inference round types/io/output.rs::OutputItem::to_input_item; use executor/gateway.rs::append_output_items_to_input only to append those converted items
Change continuation history visibility storage/types/item.rs::into_input_items → types/io/output.rs::to_input_item (preservation) → types/io/input.rs::model_input (upstream visibility)
Add a CRUD operation beyond persist/rehydrate executor/modes/conversation.rs or modes/response.rs, backed by storage/conversation.rs / storage/response.rs
Change how output items are assembled from a stream executor/accumulator/ — extend the typed slot and transition matches through the existing RoundIngestion path
Add a new Responses/Messages wire field types/io/ or types/messages/ — shape only, no behavior

Further reading

  • AGENTS.md — module boundaries, lint/format rules, commit and PR conventions
  • TERMINOLOGY.md — normative vocabulary for API/state/tool/streaming concepts
  • ROADMAP.md — project direction and near-term focus
  • docs/adr/ — architecture decision records
  • docs/design/ — as-built design docs (tool framework, core public API, MCP integration, Codex integration)