Skip to content

Composable Kernel Architecture

The Composable Kernel Architecture separates how a reasoning step works (the kernel) from when and how many times it runs (the strategy). This makes reasoning algorithms swappable, testable in isolation, and extensible without touching core framework code.

Strategy (policy: when to run, how many times, what config)
└── KernelRunner (universal loop: tool guard, EventBus wiring, state transitions)
└── ThoughtKernel (algorithm: one step — thought → action → observation)

Before this architecture: Each strategy owned its own execution loop. reactive.ts was 905 lines. Tool call handling, EventBus wiring, and observation formatting were duplicated across 5 files.

After: reactive.ts is 128 lines. All strategies call runKernel(reactKernel, ...). Tool handling lives once in tool-execution.ts.

A ThoughtKernel is the contract for a single reasoning step:

type ThoughtKernel = (
state: KernelState,
context: KernelContext,
) => Effect.Effect<KernelState, never, LLMService>;

The kernel receives immutable state and a frozen context, performs one reasoning step (think, act, or observe), and returns the next state. The runner calls it in a loop until state.status is "done" or "failed".

KernelState is immutable — each step produces a new state via transitionState(). This makes reasoning chains replayable and serializable for collective learning.

interface KernelState {
// Identity
readonly taskId: string;
readonly strategy: string;
readonly kernelType: string;
// Accumulation
readonly steps: readonly ReasoningStep[];
readonly toolsUsed: ReadonlySet<string>;
readonly scratchpad: ReadonlyMap<string, string>;
// Metrics
readonly iteration: number;
readonly tokens: number;
readonly cost: number;
// Control
readonly status: KernelStatus; // "thinking" | "acting" | "observing" | "done" | "failed"
readonly output: string | null;
readonly error: string | null;
// Strategy-specific extension point
readonly meta: Readonly<Record<string, unknown>>;
}

Use the provided factory functions — never mutate state directly:

// Create initial state
const state = initialKernelState({
maxIterations: 10,
strategy: "reactive",
kernelType: "react",
taskId: "task-abc",
});
// Produce the next state (returns a new object — does not mutate)
const nextState = transitionState(state, {
status: "acting",
iteration: state.iteration + 1,
meta: { ...state.meta, pendingToolRequest: toolReq },
});

KernelState uses ReadonlySet and ReadonlyMap which are not JSON-safe. Use the provided helpers for persistence:

// KernelState → JSON-safe object (Set → sorted array, Map → plain object)
const serialized: SerializedKernelState = serializeKernelState(state);
// JSON-safe object → KernelState (array → Set, object → Map)
const restored: KernelState = deserializeKernelState(serialized);

The context is assembled once by runKernel() and passed unchanged to every kernel step:

interface KernelContext {
readonly input: KernelInput; // frozen task inputs
readonly profile: ContextProfile; // model-adaptive thresholds
readonly compression: ResultCompressionConfig;
readonly toolService: MaybeService<ToolServiceInstance>;
readonly hooks: KernelHooks; // EventBus lifecycle callbacks
}

runKernel() is the universal execution loop. Every reasoning strategy delegates to this function instead of implementing its own while-loop.

function runKernel(
kernel: ThoughtKernel,
input: KernelInput,
options: KernelRunOptions,
): Effect.Effect<KernelState, never, LLMService>

KernelRunOptions controls iteration limits and tagging:

interface KernelRunOptions {
readonly maxIterations: number;
readonly strategy: string;
readonly kernelType: string;
readonly taskId?: string;
readonly kernelPass?: string; // descriptive label, e.g. "reflexion:generate"
readonly meta?: Record<string, unknown>;
}

The runner handles nine steps internally:

  1. Service resolution — resolves LLM, ToolService, and EventBus via Effect.serviceOption
  2. Profile merging — merges input.contextProfile over the "mid" baseline profile
  3. KernelHooks construction — builds EventBus-wired hooks via buildKernelHooks()
  4. KernelContext assembly — freezes a single context object for the entire execution
  5. Initial state creation — calls initialKernelState(options) with status: "thinking"
  6. Main loop — calls kernel(state, context) until done, failed, or maxIterations reached
  7. Embedded tool call guard — if the final output contains a bare tool call (e.g. web-search({"query":"test"})), the runner executes it and replaces the output. This guards against models that embed tool calls inside FINAL ANSWER text.
  8. Terminal hooks — fires onDone or onError
  9. Return — returns the final KernelState

The built-in reactKernel implements the Think → Act → Observe loop and is the default kernel used by all five strategies:

import { runKernel } from "./strategies/shared/kernel-runner.js";
import { reactKernel } from "./strategies/shared/react-kernel.js";
const finalState = yield* runKernel(
reactKernel,
{
task: "Summarize the latest release notes",
availableToolSchemas: schemas,
taskId: "task-123",
},
{
maxIterations: 10,
strategy: "reactive",
kernelType: "react",
},
);

For backwards compatibility, a wrapped form is also available:

import { executeReActKernel } from "./strategies/shared/react-kernel.js";
const result: ReActKernelResult = yield* executeReActKernel({
task: "Summarize the latest release notes",
availableToolSchemas: schemas,
maxIterations: 10,
parentStrategy: "reactive",
kernelPass: "reactive:main",
taskId: "task-123",
});
// result.output, result.steps, result.totalTokens, result.toolsUsed, result.iterations

KernelHooks is the single source of truth for kernel lifecycle events. It is the only place ToolCallCompleted is published, which prevents the double-counting in MetricsCollector that occurred before this architecture.

interface KernelHooks {
readonly onThought: (state: KernelState, thought: string) => Effect.Effect<void, never>;
readonly onAction: (state: KernelState, tool: string, input: string) => Effect.Effect<void, never>;
readonly onObservation: (state: KernelState, result: string) => Effect.Effect<void, never>;
readonly onDone: (state: KernelState) => Effect.Effect<void, never>;
readonly onError: (state: KernelState, error: string) => Effect.Effect<void, never>;
}

Events emitted per hook:

HookEventBus events published
onThoughtReasoningStepCompleted (with thought field)
onActionReasoningStepCompleted (with action field)
onObservationReasoningStepCompleted (with observation field) + ToolCallCompleted
onDoneFinalAnswerProduced
onError(no-op — no event emitted)

When no EventBus is present, buildKernelHooks() returns hooks that silently no-op — kernels do not need to guard against a missing EventBus.

For tests and simple runs, noopHooks is exported from kernel-state.ts:

import { noopHooks } from "./strategies/shared/kernel-state.js";
// All five hook methods are Effect.void — safe, no EventBus required

StrategyRegistry holds a second registry for ThoughtKernel instances alongside the strategy registry. Use it to register your own kernel and retrieve it by name at runtime.

class StrategyRegistry extends Context.Tag("StrategyRegistry")<
StrategyRegistry,
{
// ... strategy methods ...
/** Register a custom ThoughtKernel by name. */
readonly registerKernel: (
name: string,
kernel: ThoughtKernel,
) => Effect.Effect<void>;
/** Retrieve a registered ThoughtKernel by name. Fails with StrategyNotFoundError if absent. */
readonly getKernel: (
name: string,
) => Effect.Effect<ThoughtKernel, StrategyNotFoundError>;
/** List all registered kernel names. */
readonly listKernels: () => Effect.Effect<readonly string[]>;
}
>() {}

The built-in kernel "react" is pre-registered in StrategyRegistryLive. Custom kernels are additive — registering one does not affect built-in kernels or strategies.

import type { ThoughtKernel, KernelState, KernelContext } from "@reactive-agents/reasoning";
import { transitionState } from "@reactive-agents/reasoning";
import { Effect } from "effect";
import { LLMService } from "@reactive-agents/llm-provider";
// A minimal single-shot kernel: one LLM call, then done
const oneShotKernel: ThoughtKernel = (
state: KernelState,
context: KernelContext,
): Effect.Effect<KernelState, never, LLMService> =>
Effect.gen(function* () {
const llm = yield* LLMService;
const response = yield* llm.complete({
messages: [{ role: "user", content: context.input.task }],
maxTokens: 512,
}).pipe(Effect.orDie);
yield* context.hooks.onThought(state, response.content);
return transitionState(state, {
status: "done",
output: response.content,
tokens: state.tokens + response.usage.totalTokens,
iteration: state.iteration + 1,
});
});
// Register in your app setup
const program = Effect.gen(function* () {
const registry = yield* StrategyRegistry;
yield* registry.registerKernel("one-shot", oneShotKernel);
// Retrieve and run later
const kernel = yield* registry.getKernel("one-shot");
const finalState = yield* runKernel(kernel, { task: "Hello" }, {
maxIterations: 1,
strategy: "one-shot",
kernelType: "one-shot",
});
});
BeforeAfter
reactive.ts — 905 linesreactive.ts — 128 lines
Tool execution duplicated ×5tool-execution.ts — shared once
EventBus wiring scattered across 5 strategy fileskernel-hooks.ts — single source
Double ToolCallCompleted metrics in MetricsCollectorFixed — KernelHooks.onObservation is the only publisher
Hard to add a new strategyImplement one ThoughtKernel step function, call runKernel()
KernelState was mutableImmutable — transitionState() returns a new object each time
No bare tool call guardrunKernel() detects and executes embedded tool calls post-loop