Cortex · DESIGN.md

This is DESIGN.md as @niscorp/cortex 0.1.4 ships it. A coding agent reads it from node_modules. The file itself:

nisc.dev/cortex/DESIGN.md

@niscorp/cortex — design

Cortex is the agent runtime of the @niscorp stack. It runs typed, tool-using agents on top of @niscorp/signal (model calls), @niscorp/solid (partial-JSON streaming), and zod (validation). This document is the why; usage lives in README.md. Style rules live in niscorp/STYLE_GUIDE.md — no any, no as, no !, no enum, no classes; arrow-const exports with explicit return types; kebab-case files with role suffixes; named exports only.

This is the second design. The first (see git history) built an event-bus substrate, a producer pipeline, a JSON rules engine, and a plan-mode interpreter. A full architecture review (2026-07) found: every real consumer used only defineAgent + defineTool + runAgentStandalone; structured mode never sent the output schema to the model; the context prefix was re-templated every iteration (cache-hostile); the rules engine and stateful producers were broken in ways no consumer had hit because no consumer used them. v2 keeps what earned its place — typed tools, gating, observations, cross-library agents, context-from-functions, preview — and rebuilds the execution core around the pattern the field converged on: one tool loop with a typed exit.


1. What cortex does

  1. Agents. defineAgent<TData, TDeps> — configuration, not a class. Typed output, typed per-invocation deps.
  2. One loop. model → tools → model. Bounded, gated, observed. The only execution path; there is no second mode.
  3. The envelope. Every agent returns structured data. Terminating the loop is a message that IS the envelope — as content (emit), under provider grammar (native), or as the arguments of a synthetic respond tool, whichever the provider can enforce.
  4. Context. Functions of typed deps build the prefix once per run. The transcript is append-only after that.
  5. Gates. Code hooks before and after every tool call. Approvals suspend the run; suspended runs are serializable.
  6. Events. Every run is a typed event stream. Observability is the stream.
  7. Manifold. A catalog of agents and tools with shared defaults and a run tap. Libraries (vex, prism, nova) export agents; apps compose them; agents consume each other as tools.

Anti-goals. No HTTP, no UI, no database, no LLM SDK (signal), no JSON-parsing of streams (solid), no event-bus substrate, no declarative rules DSL, no plan interpreter (§14), no conversation persistence (callers own history).

Peers: @niscorp/signal, zod. Dependency: @niscorp/solid.


2. The envelope

Every agent returns the same shape. There is no text mode.

type Envelope<TData> = {
  response?: string;   // human-facing text
  data: TData;         // schema-typed payload (undefined for pure chat agents)
  reasoning?: string;  // model-authored: WHY it did what it did
};

Why one shape:

  • Text AND data in one turn. A chat agent can answer in prose and hand over a Nova screen in the same response. This is the common case for complex assistants, not an edge case.
  • One termination rule, one streaming path, one thing to test. A chat agent is an envelope agent with data: undefined; a classifier is one with response omitted.
  • reasoning is the model's own short justification. It is distinct from provider reasoning tokens (GLM, gpt-oss), which are runtime telemetry and surface as model-delta events with channel: 'reasoning' — never in the envelope.
  • meta is not a model field. Usage, timing, resolved strategy, attempt counts, provider metadata — all authored by cortex + signal and reported on the run result and events. Models do not self-report metadata; self-reported metadata is noise.
type RunResult<TData> =
  | { ok: true; output: Envelope<TData>; meta: RunMeta }
  | { ok: false; error: CortexError; meta: RunMeta };

type RunMeta = {
  usage: Usage;              // aggregated; per-step usage rides on events
  strategy: OutputStrategy;  // what actually ran
  steps: number;
  outputRetries: number;
  elapsedMs: number;
};

3. Output strategies

How the envelope travels from model to runtime. The output is ALWAYS the JSON envelope; a strategy only picks the TRANSPORT — which channel carries its bytes. Three transports, one config field, auto default.

auto picks the transport that ENFORCES the most, and falls to emit when nothing would enforce anything (signal transport/resolve.ts):

native   — provider grammar is viable (small schema; tools allowed
           beside response_format, or no tools)
respond  — the tool params can enforce the contract: the endpoint
           does not validate tool args itself, the full schema fits
           in the params, and the model carries nested payloads on
           the tool channel intact (or `output.forceTool` is set)
emit     — everything else. Every model can do it, and the contract
           rides the prompt — exactly as it would under a respond
           whose params enforce nothing.

On Groq every agent resolves to emit: Groq validates tool args server-side, so respond params would be sent permissive and enforce nothing. That holds whichever model Groq serves.

Strategy Transport Termination Schema reaches model via
respond tool-call arguments (synthetic respond tool) the respond call, OR an emitted envelope on the content channel tool params (small schemas) or prompt docs (large)
native content channel under provider grammar (response_format: json_schema) a content turn with no tool calls provider grammar
emit content channel; the model's completion IS the envelope a content turn with no tool calls prompt docs

Zod validates the envelope in every strategy. Providers enforce at most syntax; cortex enforces the contract.

3.1 respond

Cortex registers one synthetic tool per run:

  • Params, auto-detailed. If the serialized data schema is strict-compatible (no recursion, no open records, no refinements) and small (≤ ~8 KB serialized), it is inlined into the tool params. Otherwise params are loose (data: object, see OUTPUT SCHEMA in the system prompt) and the schema documentation is injected into context (§3.4). This is what makes Prism's NodeSchema and Nova's ActionDefinitionSchema work: a ~60-way recursive union cannot be provider-enforced anywhere — the schema was always going to live in the prompt; the strategy only decides the delivery channel of the answer.
  • Permissive params on hard-validating providers. When the provider validates tool args server-side and 400s the request on a mismatch (Groq's tool_use_failed; signal capability validatesToolArgs), the wire params name the envelope fields but constrain nothing ({ response: {}, data: {}, reasoning: {} }). A server 400 destroys exactly the attempts the client-side decode/ repair ladder saves (a stringified data is repairable); with permissive params the attempt arrives and gets repaired or a specific correction. The contract rides the respond description + the schema doc; validation is always client-side anyway.
  • Emit is a legal exit (respond-or-finish). A turn with no tool calls is first parsed (fence-tolerant) and validated as the envelope; a valid one finishes the run exactly like a respond call. Some models are unreliable tool-call finishers but emit clean JSON — both doors lead to the same validated envelope. Only a turn with no valid envelope in it is an error.
  • The unwrap rung. Models regularly produce the PAYLOAD instead of the envelope around it — especially when the payload has its own data-named field (a Nova action) colliding with the envelope's. When the output is not a plausible envelope but validates cleanly against the data schema, it is accepted as { data: <raw> }. That reading is taken only when the data schema refuses an object that holds one unknown key: a schema that accepts such an object cannot tell a bare payload from an envelope with a misspelt key, and the output is then judged as an envelope. Envelope-first precedence; safe only because real data schemas are strict and discriminating — a correct answer is never failed for missing its coat.
  • Validation feedback in-loop. Invalid respond args are appended as the assistant's turn, followed by a system message carrying the Zod issues; the model answers again. One extra step, transcript intact, tools still warm — never a full re-run.
  • respond must be called alone. If it appears alongside other tool calls in one turn, it is dropped: the other calls execute normally and the model is told nothing about it. Its arguments have already streamed as output-delta and output-partial by then.
  • Termination without a result (model stops on prose that is not an envelope) is a protocol violation: cortex appends a correction offering both exits (call respond, or emit ONLY the envelope) and continues. These corrections are not counted against outputRetries, which is for answers that parsed and were refused, so it is stepCount (20 turns by default) that ends a run whose model keeps writing prose. Per-agent hardening: output.forceTool: true sets toolChoice: 'required' so every turn must be a tool call and respond is the only exit — opt-in, because some models get tool-happy under required.
  • Groq-safe when chosen explicitly. No response_format in any request, so tools and structured output never conflict. auto does not pick it on Groq (above): the params there would enforce nothing.

3.2 native

auto picks it only when the provider capability toolsWithStructuredOutput is true (or the agent has no tools) AND the envelope schema passes the strict-compatibility walk. Small extractors and classifiers on OpenAI-class providers land here and get grammar-level enforcement for free.

3.3 emit

The envelope travels on the content channel: the model's final completion IS the envelope, as raw JSON — no tool call, no provider grammar. First-class, not degraded: same envelope, same Zod validation, same correction retries (appended messages; a refused envelope counts against outputRetries, a turn with no envelope in it does not), same solid streaming via content deltas. Fence-tolerant parse. It is auto's floor: wherever neither grammar nor tool params would enforce the contract, the contract rides the prompt either way, and emit carries the answer without a synthetic tool or a tool-call ending. Tool-call arguments are also a LOSSIER channel on some models (signal capability manglesNestedToolArgs, measured per model in signal's model registry), and some models compose very large payloads better as a completion than as function args. Explicit respond/native choices are still honored. output.forceTool cannot combine with emit — the final turn must be a content-only message.

3.4 Schema documentation

schemaDoc(schema) renders a Zod schema as prompt documentation — single-sourced from the same schema Zod validates against, so docs and validation cannot drift. When respond resolves to loose or permissive params (or strategy is emit), cortex injects OUTPUT SCHEMA … as a system chunk automatically (placement: §5). Agents that hand-author a full DSL guide (the architect pattern) disable it with output.doc: 'off' or replace it with a string.


4. The loop

prefix   = assemble(context fns, input)          — once, at run start
messages = [...prefix]                           — append-only from here on

step:
  stopWhen checks (steps, tokens, duration, outputRetries, repeatedCalls)
  prepareStep hook → activeTools mask, toolChoice, injected messages, llm swap
  signal.stepStream({ messages, tools, responseFormat? })
    → model-delta events (text + reasoning channels)
    → tool-call-delta events → output-delta / output-partial for respond args
  for each tool call, in order:
    tool gates (allow / deny / ask)               — BEFORE execution
    execute (Zod-validated input, timeout)
    onToolResult hooks (replace / redact / truncate)
    append tool result message; emit tool-end observation
  respond call → validate envelope → end (or correction + continue)

Rules the loop lives by:

  • Append-only transcript. The prefix is built once and never re-templated. Rule injections, corrections, and budget nudges are appended messages. This is the KV-cache lesson: on the models we run, cached input is ~10× cheaper than uncached, and v1 paid the uncached price on every iteration.
  • Tool calls execute sequentially, in model order. Deterministic gating and event order beat latency here; parallel execution is a future knob (§15).
  • Tool failures are observations, not exceptions. A failed or denied call becomes an error result the model sees and reacts to. The run only fails on structural conditions (stop limits, model call failure, abort, retries exhausted).
  • A tool's input is enforced; its output is a type. The input schema is parsed before execute runs. The optional output schema types what execute returns and is not applied at runtime: the return value is not parsed, stripped or transformed by it.
  • The model sees tool.name; tool.id is the policy identity. Descriptors carry the name (prompts say "call query"); the loop's own table resolves a call by name OR id, but Signal routes only the names it was sent, so through Signal a call by id is read as an attempted answer and never reaches that table; gates, observations and traces always carry the canonical id. Both namespaces must be collision-free per run, and nothing may claim respond. (v1 sent ids on the wire while documenting names — a prompt-literal model exposed the lie.)
  • Streaming is not a mode. The loop always consumes signal.stepStream; await run.result is the opt-out. There is no stream: true bifurcation to test twice.

Bounds are stopWhen predicates — one vocabulary instead of v1's maxToolIterations / maxTicks / maxDurationMs / budget tangle:

stopWhen: [stepCount(20), tokens(100_000), duration('5m'), outputRetries(3), repeatedCalls(3)]

repeatedCalls(n) ends a run whose model keeps sending the same tool call and getting the same answer — every further step would cost a full prompt and change nothing.

Defaults: stepCount(20), outputRetries(3). No default duration or token cap — v1's 60-second default was overridden at every call site, which is what a wrong default looks like.


5. Context — producers

The v1 producer insight was right — context comes from PRODUCERS, and how a producer constructs its content is irrelevant. v1 failed on machinery (priorities, state, no per-invocation data); the machinery is deleted, the concept and the name stay:

// What enters context: one system chunk, several (a producer can emit a
// GROUP — each string its own system message), or raw messages.
type ContextEntry = string | string[] | Message[];

// What makes one. Always a function — the name says so. Annotate shared
// definitions `satisfies Producer` so what a thing is stays obvious at
// the definition site while the value keeps its narrow callable type.
type Producer<TDeps = undefined> =
  (ctx: ProducerArgs<TDeps>) => ContextEntry | Promise<ContextEntry>;

type ProducerArgs<TDeps> = { deps: TDeps; input: RunInput; agent: AgentInfo };

// defineAgent
context?: ReadonlyArray<ContextEntry | Producer<TDeps>>;

The principle: context is owned by whoever owns the knowledge, and composed by spreading. An agent owns its identity (instructions). A LIBRARY owns its contract and exports it as a producer (vex's vexGuide(), prism's schema). An APP owns its ambient facts and exports its shared set ([...appProducers(), …] — the same list attached to every agent, so "one agent knew today, the other didn't" cannot happen). A TOOL owns its own usage knowledge (below). Nothing is ever hand-summarized into a prompt on behalf of another owner — that is how knowledge drifts.

  • Strings and string returns become system messages; Message[] returns give full control. Order is the array — placement IS the array position ("today first" = put it first). No priorities, no eviction, no compressors, no token modes, no producer state.
  • instructions is sugar for the first producer. It exists once.
  • RunOptions.producers appends per-run producers after the agent's own — an app attaches shared knowledge to ANY agent without editing its definition.
  • Tools bring their own guides. defineTool({ guide }) carries the tool's usage knowledge (a string, a string[] of lines, or a deferred function returning either, e.g. composing a library's exported guide). The run assembles one TOOL GUIDES section from the ACTIVE tools — add a tool and its guide arrives, change it and every agent updates, remove it and the guide leaves. Instructions never describe tools.
  • Prefix order: instructions → tool guides → schema doc → finish protocol → agent producers → run producers → input. The three middle blocks are fixed for the life of an agent, so they sit ahead of the producers, which are not — a provider's prefix cache stops at the first byte that changed. The finish protocol is ONE chunk — authored by signal's transport, injected by cortex verbatim — stating how the run ends under the RESOLVED transport (call respond / emit the envelope / reply under grammar) — agents never author finish lines; they cannot know which transport resolution picked.
  • Producers run once, at run start, to build the prefix. Dynamic steering mid-run belongs to prepareStep (append a note, mask tools, force a tool choice, swap the model) — the 20% case as one hook instead of a rules engine.
  • input is string | Message[] | unknown (non-message values are JSON-stringified into the user turn). Multi-turn history is caller-owned and arrives as Message[] — ray's transcript becomes the supported path instead of a workaround.
  • Compaction (summarize-when-near-limit) is a future single hook on the transcript (§15), not a v1 pipeline.

Preview survives, better. await agent.preview(input, { deps }) returns the exact assembled messages, the resolved tool list (including respond and its actual params), the resolved strategy, and a token estimate — no model call. Anything you can't explain in the preview, the model can't either.


6. Gates and hooks

All steering is plain typed functions in arrays, composed in order. Declarative config is sugar that compiles into the same arrays — never a parallel engine.

type ToolGate<TDeps> = (call: ToolCallInfo, ctx: RunCtx<TDeps>) =>
  | { allow: true; args?: unknown }     // optionally rewrite args
  | { deny: string }                    // model sees a tool error
  | { ask: { reason: string } }         // suspend for approval
  | Promise<…>;

type ToolResultHook<TDeps> = (obs: ToolObservation, ctx: RunCtx<TDeps>) =>
  { result?: unknown } | void | Promise<…>;   // replace / redact / truncate

type PrepareStep<TDeps> = (s: PrepareStepInfo<TDeps>) =>
  { activeTools?: string[]; toolChoice?: ToolChoice; inject?: Message[]; llm?: SignalClient } | void | Promise<…>;

type StopCondition = (s: RunProgress) => StopVerdict;   // { stop: StopReason; message } | null
  • Gates run before execution. A deny reaches the model as a tool error; the run continues. (v1 evaluated rules after the observation — a rule could never stop the call that tripped it.)

  • Policy sugar covers the declarative 80%:

    policy: {
      tools: { allow, deny, requireApproval, maxRiskLevel },
      approvalTimeoutMs,   // optional; no timeout by default
    }
    

    compiles to one built-in gate. Anything the sugar can't express is a function.

  • Approvals suspend the run. ask emits approval-required with an id that is that ask's for as long as the run handle lives — the event, run.approve / run.deny and run.snapshot() all name it; the pending call blocks (tools are sequential, so one pending approval at a time). run.approve(id, { args? }) — approve, optionally with edited args — or run.deny(id, reason). An id the handle did not ask under is ignored. run.snapshot() serializes the suspended run (messages, pending call, usage); resumeRun(agent, snapshot, opts) restores it — so approvals survive reloads and restarts. A resumed run re-asks: its gates run again for the pending call (if they no longer ask, the call simply runs) and approval-required fires with a new id — the one the resumed handle answers to. snapshot.pending.approvalId names the ask of the run the snapshot came from, not the resumed one's.

  • Output validation closes the generate→verify loop in-run:

    output: {
      schema: ActionAgentOutputSchema,
      validate: async (out) => ok ? { ok: true } : { retry: issues },  // async, can do I/O
    }
    

    A failed validator feeds back like a failed Zod parse — correction in the same run, tools still warm. (This replaces the architect's hand-rolled verify-once harness bolt-on.)

What hooks deliberately can't do: react to other runs (cross-run choreography is app code around manifold.onRun), mutate past transcript (append-only is load-bearing), veto text mid-generation (validation happens at boundaries: tool call, step end, output). If remotely-configured steering is ever needed, it lands as a compiler from a config shape to a gate function — on top, not underneath.


7. Events and observability

Every run exposes one ordered, typed stream. No global bus.

const run = agent.run(input, { deps });
for await (const e of run.events) { … }
const result = await run.result;
Event Payload Notes
run-start { input }
step-start { step }
model-delta { text, channel: 'text' | 'reasoning' } provider reasoning tokens land here
tool-start { call } before gates + execution — drives live UIs
tool-end { observation } typed union incl. denials; no casting
approval-required { approval: { id, toolId, callId, args, reason } } run suspends
output-delta { text } the output channel as it streams; the envelope's JSON on the step that ends the run
output-partial { output } solid-parsed partial envelope
retry { kind: 'output' | 'termination' | 'provider', attempt, issues } consumers reset partial state; issues carries the evidence (Zod issues, the rejected attempt, or the stray text)
run-end { result, meta }

Every event carries { runId, agentPath, seq, ts }. When an agent runs inside another (asTool), the child's events forward into the parent stream with the extended agentPath — one subscription sees the whole tree. Per-step usage rides on step events and aggregates into RunMeta.usage; that is the whole ledger.

Process-level watching is the manifold's tap: onRun(run => …) hands you every run created through it. That is the one legitimate job the v1 bus had, kept, without the bus. (Precedent: AutoGen v0.4 built the bus-first agent substrate; Microsoft retired it within a year for plain awaited runs with typed-edge composition.)


8. The manifold

A catalog, defaults, and the tap. Not a lifecycle, not a substrate, not an execution path.

const manifold = createManifold({
  llm: groq120b,                    // default model
  gates: [uiApprovalGate],          // shared gates
  onRun: (run) => trace.attach(run),
});

manifold.register(vexQueryAgent, novaLayoutAgent, queryTool);

const run = manifold.run('action.builder', { intent }, { deps: env });
const asToolDef = manifold.asTool('vex.query', { description });
  • agent.run() standalone and manifold.run() are the same function; the manifold merges defaults. There is no runAgentStandalone.
  • Agents may bind their own llm; the manifold's is the fallback. (The architect's GLM-reasoning / Groq-support split becomes configuration.)
  • asTool(agent, { description?, select? }) wraps an agent as an ordinary ToolDefinition. Default result mapping: envelope data if present, else response; select overrides. Child events forward (§7).
  • Duplicate ids throw at registration. Registration is a Map insert; there is no start/stop/drain.

9. Errors

Same two principles as v1, smaller taxonomy:

  1. Programmer errors throw at definition/registration time: duplicate ids, malformed configs.
  2. Runtime conditions return as Result (§2). Tool failures are observations (§4), not run failures.
type CortexError = {
  code: 'model_call_failed' | 'output_invalid' | 'stopped' | 'aborted' | 'unknown';
  stop?: StopReason;   // when code === 'stopped':
                       // 'steps' | 'tokens' | 'duration' | 'output_retries' | 'repeated_calls' | 'custom'
  message: string;
  runId: string;
  agentPath: ReadonlyArray<string>;
  lastOutput?: unknown;   // the last invalid output when the run died on output retries
  cause?: unknown;
};

An approval timeout is NOT an error code: it denies the pending call (an observation the model reacts to) and the run continues — same principle as every other tool denial.


10. Streaming

Always on (§4). Solid is a dependency and powers output-partial: respond arg deltas (or content deltas under native/emit) stream through solid's partial parser into progressively-typed envelopes — a chat response string streams token-ish while it forms inside the JSON, and a Nova screen in data becomes renderable before the run ends. The retry event resets partial-output state, same contract as v1's cortex.agent.retry.


11. Public API surface

// definition
defineAgent<TData, TDeps>(config): AgentDefinition<TData, TDeps>
defineTool(config): ToolDefinition                     // v1 shape, kept
schemaDoc(schema, opts?): string

// execution
agent.run(input, { deps, llm?, tools?, producers?, gates?, onToolResult?, onEvent?, signal? }): RunHandle<TData>
//   tools: per-run tools whose execute closes over per-invocation
//   dependencies (vex builds its query tools around the caller's
//   adapter + schema); appended to the agent's static tools.
agent.preview(input, { deps }): Promise<ResolvedPreview>
resumeRun(agent, snapshot, opts): RunHandle<TData>

// RunHandle
run.events: AsyncIterable<CortexEvent>
run.onEvent(listener): Unsubscribe
run.result: Promise<RunResult<TData>>
run.approve(id, { args? }) / run.deny(id, reason?)
run.snapshot(): RunSnapshot
run.abort(reason?)
//   the reason is `error.cause` of the `aborted` result. A tool's
//   ctx.signal still aborts with an AbortError, never with the reason.

// composition
createManifold({ llm?, gates?, onRun? }): Manifold
manifold.register / run / asTool / preview / agent / tool / agents / tools
asTool(agent, { id?, description?, llm?, timeoutMs?, deps, select? }): ToolDefinition

// gates
policyGate(policy): ToolGate

// stop conditions
stepCount(n), tokens(n), duration(ms | '5m'), outputRetries(n), repeatedCalls(n)
DEFAULT_STOP_CONDITIONS   // [stepCount(20), outputRetries(3)]

defineAgent config:

{
  id, description?,
  llm?,                       // per-agent binding; run option overrides it, manifold is the fallback
  transport?: 'stream' | 'step',   // default 'stream'; 'step' makes non-streaming calls (no deltas, no partials)
  instructions,               // string | (ctx) => string — sugar for the first producer
  context?: (ContextEntry | Producer<TDeps>)[],   // compose shared sets by spreading: [...appProducers(), …]
  tools?: ToolDefinition[],   // definitions, not id strings; each may carry its own `guide`
  output?: {
    schema?: ZodType<TData>,          // omitted → pure chat agent (data: undefined)
    response?: 'required' | 'optional',  // default: required without schema, optional with
    strategy?: 'auto' | 'respond' | 'native' | 'emit',   // default 'auto'
    forceTool?: boolean,              // toolChoice:'required' hardening
    doc?: 'auto' | 'off' | string,    // schema docs injection, default 'auto'
    validate?: (out) => { ok: true } | { retry: string } | Promise<…>,
  },
  toolGates?, onToolResult?, prepareStep?, stopWhen?, policy?,
}

12. Signal changes this design requires

Items 1–3 are built; the text below is the requirement as it was written. The tokenizer in item 4 has not landed.

  1. stepStream emits tool-call-delta — { index, id?, name?, argsText } fragments as function args stream. The SSE chunks already contain them; the adapter currently drops them. This is the enabler for respond-strategy streaming and solid, and is built first.
  2. StepRequest.responseFormat passthrough (json_schema / json_object) for the native strategy.
  3. Capabilities truth. Groq nativeTools: true (stale since the registry was written; ray proves it daily). New capability: toolsWithStructuredOutput: boolean — encodes the can't-combine-format-and-tools constraint that shaped §3.
  4. Later, not blocking: signal's own complete() tool loop can slim once cortex is the loop owner; real tokenizer behind count().

13. What v2 deletes from v1

Deleted Why
Event bus, topics, wildcard matching, typed-topic substrate carried no load; per-run event streams + onRun cover the real uses
Rules engine (watch/when/then, accumulators, effects) underpowered (counter thresholds), broken (global un-reset state, dead call, deny-all-forever, duplicating inject), post-hoc by construction; gates + prepareStep replace it
Plan mode: ActionPlan schema, plan executor, node handlers, tick loop ~830 lines duplicating what the tool loop does, fed by fragile text parse; delegation is asTool; revisit as a todo/recitation tool if ever needed (§15)
Producer pipeline: priorities, eviction, compressors, token modes, producer state, stores fought its consumers (no deps param), features unreachable or fake (exact mode ≡ fuzzy, per-producer compress unreachable, stateful producers had no read path)
Manifold lifecycle (start/stop/drain), ledger object, state store, event log decorative or folded into run meta/events
runAgentStandalone agent.run() is the standalone path
Full-loop validation retries in-loop corrections (§3.1)

14. Plan mode, revisited later — the record

v1 bet on model-emitted ActionPlans executed by a runtime. The field data: BabyAGI (the pattern's origin) is archived; AutoGPT pivoted away; every mainstream SDK (OpenAI Agents, Vercel, Pydantic AI, Mastra) ships a native tool loop with code-first composition; the survivors of "planning" are recitation todo-lists the model itself maintains in-loop (Manus todo.md, Claude Code TodoWrite). If explicit planning returns to cortex it returns as that: a small todo tool plus a context entry, on top of the loop — not an interpreter under it.


15. Future work (known, deferred)

  • Compaction — one hook: summarize the transcript when a token threshold nears, keep the prefix stable. Field-standard; not needed for current workloads.
  • Parallel tool execution — opt-in per agent once gate/event ordering under concurrency is specified.
  • Durable run store — snapshot() already serializes; a store interface + replay is additive.
  • Declarative gate config — a compiler from config to gate functions, if remote-configured steering is ever needed.
  • Exact token counting — behind signal.count() when a real tokenizer lands.
  • respond vs emit A/B on the big-DSL agents (prism mapping, nova layout, architect). Settled by RULE, not by measurement (2026-09-24): their schemas are too large for enforcing respond params, so auto resolves them to emit everywhere. The quality comparison was never run.

16. Source layout

src/
├── index.ts                      # public API
├── types.ts                      # shared core types
│
├── schemas/
│   ├── index.ts
│   ├── envelope.schema.ts        # envelope wire / accept schemas, validateEnvelope
│   └── tool-config.schema.ts
│
├── agent/
│   ├── define-agent.ts
│   ├── run.ts                    # RunHandle, resumeRun, prefix assembly
│   └── preview.ts
│
├── loop/
│   ├── loop.ts                   # the loop (§4) — all three transports
│   └── partials.ts               # solid partial-output tracking
│                                 # (auto resolution, the strict-compat walk, the respond
│                                 #  descriptor and corrections are signal's: src/transport/)
│
├── context/
│   ├── assemble.ts
│   └── schema-doc.ts
│
├── gates/
│   ├── types.ts
│   ├── policy.ts                 # sugar → built-in gate
│   ├── stop.ts                   # stop conditions + defaults
│   └── approval.ts               # suspend / approve / deny
│
├── events/
│   ├── types.ts                  # CortexEvent vocabulary
│   └── channel.ts                # per-run emitter + forwarding
│
├── manifold/
│   ├── manifold.ts
│   ├── as-tool.ts
│   └── types.ts                  # erased agent shapes
│
├── tool/define-tool.ts
├── errors/cortex.errors.ts
└── utils/