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 ZodError with 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() and ctx.recv() are scoped to the current execution
  • Abort signaling -- ctx.abort() propagates cancellation through the execution tree
  • Parent traversal -- ctx.parent provides 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:

HookTransition
initExecution starts -- load persisted state
recallBefore LLM call -- inject context into the prompt
storeAfter LLM response -- extract and persist new knowledge
onSpawnChild agent created -- decide what state to share
onReturnChild agent finished -- merge results back
onCompleteExecution ends -- finalize with outcome metadata
disposeCleanup -- 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:

  1. Guarantee each layer its min tokens
  2. Distribute remaining capacity proportionally up to each layer's max
  3. Reserve responseReserve tokens for the model's response
  4. Apply the overflow strategy 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 a select function
  • '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.

On this page