Dispatcher Contract
Purpose: Define the provider-neutral request/result protocol and the adapter-owned execution responsibilities for formula agents.
Read when: changing DispatchRequest, DispatchResult, an adapter dispatcher, or cross-adapter routing.
Skip when: changing an interactive provider UI that never invokes formula dispatch.
Read next: Codex adapter, Claude SDK, adapter parity.
Nav: Section Index | Docs Index
Source of truth. Update this contract before extending dispatcher behavior. A disagreement is drift to repair, not permission for the code to redefine the contract silently.
Purpose
Formula agents (F1..F11) need to run somewhere. The runtime is different per agent CLI:
| Agent | Spawn channel | SDK kind |
|---|---|---|
| Claude | claude-agent-sdk.query() |
required Python library |
| Codex | default codex exec --json; optional openai-codex app-server |
stable CLI plus opt-in beta Python SDK with a pinned runtime |
| any | DefaultDispatcher (DB-only fallback) |
n/a - inline only |
The contract here is the agent-agnostic shape every dispatcher must
satisfy so the supervisor (cos_supervise, cos_dispatch_formula) does not
need to know which runtime it is talking to.
Contract Surface
Defined in src/core/thinking_os/dispatcher.py.
IO models
class DispatchRequest(BaseModel):
formula_id: str # safe slug — embeddable in filenames
agent_file: str # absolute or thinking_os-relative path
prompt: str # composed system+user prompt
input_slice: dict # upstream-only EvidenceBundle view
persona_id: str | None
intensity: Literal["light", "standard", "full"]
allowed_tools: list[str]
timeout_s: float
session_id: str | None
cwd: str | None
model: str | None # forwarded to the adapter; None = adapter default
max_budget_usd: float | None
long_context: bool
adapter: str | None # target-runtime HINT (e.g. "codex"); see below
max_turns: int | None # adapter-owned cap when the runtime supports it
class DispatchResult(BaseModel):
formula_id: str
status: Literal["ok", "timeout", "error", "skipped"]
output_json: dict # validated against formula's output_schema
latency_ms: int
error: str | None
dispatcher_name: str # "claude-sdk" | "codex-sdk" | "default" | ...
raw_transcript: str | None
Protocol
@runtime_checkable
class AgentDispatcher(Protocol):
name: str
async def dispatch(self, request: DispatchRequest) -> DispatchResult: ...
def available(self) -> bool: ...
Status semantics
status |
Meaning | Caller action |
|---|---|---|
ok |
Sub-agent ran, returned valid output_json |
Validate against formula schema, persist |
timeout |
Sub-agent exceeded timeout_s |
Surface as transient; main agent may retry |
error |
SDK failure, parse failure, missing agent file, subprocess rc≠0 | Surface as internal; do not silently downgrade |
skipped |
Dispatcher cannot spawn (no SDK, no binary, stub adapter) | Main agent inlines the formula and records output |
Architecture
┌──────────────────────────────────────────┐
│ src/core/thinking_os/dispatcher.py │ agent-agnostic
│ • DispatchRequest / DispatchResult │
│ • AgentDispatcher Protocol │
│ • get_dispatcher() factory │
│ src/core/thinking_os/dispatcher_helpers.py │
│ • load_agent_prompt() │
│ • extract_json_block() │
└─────────────────┬────────────────────────┘
│ importlib (path-based; no static link)
┌──────────────┼───────────────────────────┐
▼ ▼ ▼
src/adapters/claude/ src/adapters/codex/ src/core/thinking_os/dispatchers/
sdk_dispatcher.py sdk_dispatcher.py default.py
│ │ │
▼ ▼ ▼
claude-agent-sdk codex CLI default DB-only fallback
Python SDK opt-in (skipped)
Why three implementations and not one: the SDKs are different runtimes, not different views of the same runtime. A unified body would either have to re-implement what each SDK does (more code, more surface to break) or abstract over differences that do not exist as a real abstraction (in-proc async generator vs. subprocess vs. nothing). Hexagonal here gives us:
src/core/agent-agnostic (Rule 1)- adapters self-contained (Principle P8)
- new agents add a folder, not a switch statement
Factory rules (get_dispatcher)
- If
COS_FORCE_DEFAULT_DISPATCHER=1→DefaultDispatcher. Tests use this. - Detect agent from
COS_AGENTenv, thenCOS_AGENT_DIRfolder name. - Try to load
src/adapters/<agent>/sdk_dispatcher.py::build_dispatcher(). - Call
available(); ifFalse, fall through toDefaultDispatcher. DefaultDispatcher.available()is alwaysTrue— last-resort path.
The loader is importlib.util.spec_from_file_location so src/core/ never has
a static import on src/adapters/.
- Adapter hint, not adapter switch.
DispatchRequest.adapteris a HINT: when set and different from the session's resolved adapter,get_dispatcher(request=…)logs a warning naming both and proceeds on the session adapter — one adapter per session remains the invariant. The field exists so supervisor decisions (presetroles_adapter_hints, TASK-321) have a typed carrier today; honoring the hint with a real cross-adapter dispatch is the explicit follow-up seam, not implied behaviour. Per-call cost ceilings ride onmax_budget_usd; a separateadapter_budget_usdcarrier was removed (audit 2026-06 F-axis-5 — it was never read, only defaulted).
Parity rules
Every adapter dispatcher MUST:
- Import
DispatchRequest/DispatchResultfromthinking_os.dispatcher(not by relative path orsys.pathinjection). - Use
load_agent_promptandextract_json_blockfromthinking_os.dispatcher_helpersrather than re-implementing them. - Expose
build_dispatcher() -> AgentDispatcheras the factory entry-point. - Return
status="error"(not raise) on FileNotFoundError, SDK import failure, subprocess rc≠0, parse failure, etc. - Set
dispatcher_nameto the same string asself.name. - Forward
model; surface unsupported budget, context, tool, or turn controls instead of silently dropping them. - Never retry on another backend after a provider turn may have started; duplicate execution is worse than a visible error.
The Codex CLI backend additionally MUST use the current non-interactive surface (codex exec), write the prompt to stdin, parse JSONL events, and run formula output in a read-only sandbox with approvals disabled. Formula dispatch ignores user configuration, disables hooks, and clears MCP servers so host customizations cannot recurse into or mutate a supervised sub-run. The optional Python SDK is beta and selected explicitly. Its availability is independent of a global CLI installation because published SDK builds include a pinned runtime; normal SDK dispatch must not replace that runtime with a newer system binary implicitly.
Adding a new adapter dispatcher
- Create
src/adapters/<agent>/sdk_dispatcher.py. - Import the contract + helpers from
thinking_os.*. - Implement a class with
name,available(), andasync dispatch(). - Add
build_dispatcher() -> YourDispatcher. - Add a parity test in
tests/test_adapter_parity.py. - Update
src/adapters/<agent>/adapter.yamlif presence/hook capabilities change. No edits tosrc/core/are needed.
Tests
| File | What it covers |
|---|---|
src/core/thinking_os/tests/test_dispatcher.py |
Protocol shape, factory, default path |
src/core/thinking_os/tests/test_dispatcher.py |
Codex CLI/SDK paths and request parity |
tests/test_adapter_parity.py |
Hook + dispatcher parity across agents |