Design Decisions
Key architectural choices behind Noetic and the reasoning that shaped them.
Why Discriminated Unions for Steps
Every step has a kind property ('runCode', 'callModel', 'invokeTool', 'conditional', 'inParallel', 'spawn', 'loop'). This lets TypeScript narrow types at compile time and the runtime dispatch at execution time -- no class hierarchies, no instanceof checks.
Discriminated unions also make steps serializable as plain data. You can log, inspect, and reconstruct any step from its JSON representation.
type Step<TContext, I, O> =
| StepRunCode<TContext, I, O>
| StepCallModel<TContext, I, O>
| StepInvokeTool<TContext, I, O>
| StepConditional<TContext, I, O>
| StepInParallel<TContext, I, O>
| StepSpawn<TContext, I, O>
| StepLoop<TContext, I, O>;TContext defaults to ContextData, so Step<I, O> still works when you do not need typed context access.
The kind field is the discriminant. A simple switch (step.kind) gives you exhaustive handling with full type narrowing.
Why Zod by Default, Standard Schema at the Model Boundary
Tool inputs, tool outputs, structured LLM output, and ACP agent structured output accept any Standard Schema v1 validator, with Zod as the default and fast path. Everything else — channel payloads, plan nodes, layer state schemas, item extension schemas, JSON workflow schemas — remains Zod-specific. Zod as the default means:
- Runtime type safety at every system boundary (user input, LLM output, tool results, channel messages)
- Automatic TypeScript inference -- define a schema once, get the type for free
- Consistent error messages --
ZodErrorwith path information everywhere; non-Zod validation failures are adapted into a syntheticZodErrorso the error surface stays single - JSON Schema generation for LLM function calling -- Zod schemas translate directly (via
z.toJSONSchema) to the format models expect
Non-Zod validators are validated through the Standard Schema ~standard.validate hook (sync or Promise). Model-facing JSON Schema uses the companion Standard JSON Schema v1 protocol designed by the Zod, Valibot, and ArkType authors. Noetic resolves Zod first for compatibility with Zod versions before 4.2, then ~standard.jsonSchema.input({ target: 'draft-07' }), then an explicit inputJsonSchema / outputJsonSchema override or fallback. This keeps the Zod-bound @openrouter/agent adaptation self-contained without adding per-validator runtime dependencies.
The alternative (separate runtime validation + TypeScript types) leads to drift and duplication.
Why Context Threading
Every step function receives a Context object rather than relying on global state. This design choice enables:
- Parallel execution -- parallel paths each get their own context without interference
- Metrics accumulation -- token counts, cost, and elapsed time are tracked per execution
- Channel access --
ctx.send()andctx.recv()are scoped to the current execution - Abort signaling --
ctx.abort()propagates cancellation through the execution tree - Parent traversal --
ctx.parentprovides access to the spawning context
Why the Layer Lifecycle Has 7 Hooks
The lifecycle init -> recall -> store -> onSpawn -> onReturn -> onComplete -> dispose covers every transition an agent goes through:
| Hook | Transition |
|---|---|
init | Execution starts -- load persisted state |
recall | Before LLM call -- inject context into the prompt |
store | After LLM response -- extract and persist new knowledge |
onSpawn | Child agent created -- decide what state to share |
onReturn | Child agent finished -- merge results back |
onComplete | Execution ends -- finalize with outcome metadata |
dispose | Cleanup -- release resources |
Fewer hooks would force workarounds (e.g., detecting spawn inside store). More hooks would add unnecessary complexity. Seven covers the complete lifecycle without redundancy.
Immutable Items
Items in the ItemLog are readonly and append-only. Every item has a readonly modifier on all fields. This design:
- Makes the conversation history auditable -- nothing is silently modified
- Prevents accidental mutation across concurrent parallel paths
- Enables safe cloning for spawn operations
- Simplifies debugging -- the log is a complete, unmodified record
interface MessageItem extends ItemBase {
readonly type: 'message';
readonly role: 'user' | 'assistant' | 'system' | 'developer';
readonly content: ContentPart[];
}Token Budgeting
Context layers declare budgets (number | { min, max } | 'auto'), and the projector allocates tokens across layers. This prevents any single layer from dominating the context window.
The allocation algorithm:
- Guarantee each layer its
mintokens - Distribute remaining capacity proportionally up to each layer's
max - Reserve
responseReservetokens for the model's response - Apply the
overflowstrategy if total recall still exceeds the budget
This ensures the LLM always has room for a response, and layers compete fairly for prompt space.
Context Flow for Spawn
Rather than a fixed inheritance model, spawn() starts every child with an empty ItemLog and delegates context flow to spawn-local context layers:
onSpawnhooks decide what the child starts with. Each layer can derive a child state from the parent's state and inject items (relevant history, cached knowledge, instructions) into the child's fresh context.onReturnhooks decide what flows back. Each layer can merge the child's final state into the parent's and transform the child's result (summarize, filter, restructure) before it reaches the parent.
Because context flow is just layers, the same mechanism scales from "fully fresh child" (no layers) to selective history injection, summarized hand-backs, and typed state merging — enabling compositions like a fresh context per iteration, or a fresh child whose result is distilled before it returns. See Spawn for the full mechanics.
Why No Class-Based Agents
Agents are configurations (AgentConfig), not classes. A config is just data:
- Serializable -- save, load, and transmit agent definitions
- Composable -- spread and merge configs
- Testable -- no instantiation needed, just assert on the data
- Declarative -- the runtime does the work, the config declares intent
The AgentHarness interface handles execution. This separation of data from behavior makes agents easier to reason about and test.
Related
- Overview -- architecture summary
- Context Layer System -- budget allocation details
- Spawn -- context strategies in depth
- API Reference -- complete type definitions