NOETIC
Framework

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 -- ZodError with path information everywhere; non-Zod validation failures are adapted into a synthetic ZodError so 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() 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 Layer 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 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:

  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 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:

  • onSpawn hooks 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.
  • onReturn hooks 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.

On this page