Design Decisions
Key architectural choices behind Noetic and the reasoning that shaped them.
Why Discriminated Unions for Steps
Every step has a kind property ('run', 'llm', 'tool', 'branch', 'fork', '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<I, O> =
| StepRun<I, O>
| StepLLM<I, O>
| StepTool<I, O>
| StepBranch<I, O>
| StepFork<I, O>
| StepSpawn<I, O>
| StepLoop<I, O>;The kind field is the discriminant. A simple switch (step.kind) gives you exhaustive handling with full type narrowing.
Why Zod Everywhere
Tool inputs, tool outputs, channel payloads, structured LLM output, plan nodes, and memory schemas all use Zod. A single validation library 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 -- Zod's
ZodErrorwith path information everywhere - JSON Schema generation for LLM function calling -- Zod schemas translate directly to the format models expect
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 -- forked 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 Memory 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 fork branches
- 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
Memory 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 Strategies for Spawn
Rather than a single inheritance model, spawn() offers four contextIn strategies and three contextOut strategies:
Context In:
'inherit'-- child gets a copy of the parent's full history'fresh'-- child starts empty, receives only the input string'subset'-- child gets a filtered selection via aselectfunction'custom'-- you build the child's initial items from scratch
Context Out:
'full'-- return the child's raw output'summary'-- summarize the child's output via an LLM call'schema'-- parse the child's output through a Zod schema
This flexibility enables patterns like Ralph Wiggum (fresh context per iteration), recursive LLM (fresh with summary out), and task trees (inherit with full out).
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 Runtime interface handles execution. This separation of data from behavior makes agents easier to reason about and test.
Related
- Overview -- architecture summary
- Memory Layer System -- budget allocation details
- Spawn -- context strategies in depth
- API Reference -- complete type definitions