| name | kernel-extension |
| description | Add new behavior to the composable kernel — new Phase, Guard, MetaTool, or custom kernel variant. Use when extending agent reasoning, adding tool call filtering, or building a custom kernel for a new strategy. |
| user-invocable | false |
Kernel Extension — Composable Phase Architecture
Decision Tree: What Are You Adding?
Does it need to READ the LLM response and TRANSFORM kernel state per-turn?
YES → Phase
Does it need to BLOCK or MODIFY a specific tool call before execution?
YES → Guard
Does it need to INTERCEPT a specific named tool call and return a synthetic result?
YES → MetaTool entry in metaToolRegistry
Do you need a completely DIFFERENT phase pipeline for a new strategy?
YES → Custom Kernel via makeKernel({ phases: [...] })
When in doubt: Guards are simpler than Phases. Phases are simpler than custom kernels.
Adding a Phase
File location
packages/reasoning/src/kernel/capabilities/<cap>/<name>.ts (pick the capability that
fits: reason/, act/, reflect/, verify/, attend/, etc. The old
strategies/kernel/phases/ tree was removed in the Stage-5 capability re-layout.)
Exact type signature (no deviations)
import { Effect } from "effect";
import { LLMService } from "@reactive-agents/llm-provider";
import { KernelState, KernelContext } from "../../state/kernel-state.js";
export const myPhase = (
state: KernelState,
context: KernelContext,
): Effect.Effect<KernelState, never, LLMService> =>
Effect.gen(function* () {
const lastStep = state.steps.at(-1);
return {
...state,
myNewField: "computed value",
};
});
Wire into the kernel
import { makeKernel } from "../loop/react-kernel.js";
import { handleThinking } from "./reason/think.js";
import { handleActing } from "./act/act.js";
import { myPhase } from "./reason/my-phase.js";
const kernel = makeKernel({
phases: [handleThinking, myPhase, handleActing],
});
Rules
- Phases are pure functions of
(state, context) → Effect<state>
- NEVER mutate
state directly — always return a new object via spread
- NEVER add per-turn logic to
kernel/loop/runner.ts — that's what phases are for
- A phase that calls LLMService should be placed where
think.ts is or alongside it
Adding a Guard
Location
packages/reasoning/src/kernel/capabilities/act/guard.ts
Exact type signature
import { Guard, GuardOutcome } from "../kernel-state.js";
export const myGuard: Guard = (
toolCall: { name: string; input: unknown },
state: KernelState,
input: unknown,
): GuardOutcome =>
toolCall.name === "forbidden-tool"
? { block: true, reason: "This tool is blocked by myGuard." }
: { allow: true };
Register for all strategies (default guards)
export const defaultGuards: Guard[] = [
existingGuard1,
deduplicationGuard,
myGuard,
];
Register for a single strategy only
const kernel = makeKernel({
phases: [contextBuilder, think, guard, act],
});
Rules
- Guards are SYNCHRONOUS — no
Effect, no async, no yield*
- Return exactly
{ allow: true } or { block: true; reason: string } — nothing else
- Guards run in array order; first
block wins
- A blocked tool call is logged but does NOT end the run — the LLM gets the block reason and continues
Adding a MetaTool
Location
packages/reasoning/src/kernel/capabilities/act/act.ts
Pattern
const metaToolRegistry: Record<string, MetaToolHandler> = {
"pulse": pulseHandler,
"brief": briefHandler,
"my-meta-tool": async (args, state, context) => {
const result = computeResult(args);
return {
content: JSON.stringify(result),
success: true,
};
},
};
When MetaTool vs real Tool
| Use | When |
|---|
| MetaTool | Intercepts a known tool name, synthesizes result from in-memory state, no external I/O |
| Real Tool | Needs ToolService registration, may do HTTP/file/process I/O, follows ToolDefinition schema |
Custom Kernel
Use when a strategy needs a fundamentally different phase sequence:
import { makeKernel } from "../loop/react-kernel.js";
import { handleActing } from "./act/act.js";
export const myCustomKernel = makeKernel({
phases: [myThink, handleActing],
});
export const myStrategy: ReasoningStrategy = {
name: "my-strategy",
execute: (input) =>
Effect.gen(function* () {
const result = yield* myCustomKernel(input);
return result;
}),
};
Testing a Phase
Every phase test needs a timeout. Use a mock LLMService layer.
import { Effect, Layer } from "effect";
import { describe, it, expect } from "bun:test";
import { myPhase } from "../../../../src/kernel/capabilities/reason/my-phase.js";
import { LLMService } from "@reactive-agents/llm-provider";
import { makeMockLLM } from "@reactive-agents/testing";
describe("myPhase", () => {
const mockLLMLayer = Layer.succeed(LLMService, makeMockLLM({
defaultResponse: "mock response",
}));
const makeState = (overrides = {}) => ({
messages: [],
steps: [],
iteration: 0,
status: "running" as const,
...overrides,
});
it("should transform state correctly", async () => {
const state = makeState({ iteration: 1 });
const context = { task: "test task", agentId: "agent-1" };
const result = await myPhase(state, context).pipe(
Effect.provide(mockLLMLayer),
Effect.runPromise,
);
expect(result.myNewField).toBe("expected value");
}, 15000);
it("should not modify unrelated state fields", async () => {
const state = makeState({ messages: [{ role: "user", content: "hi" }] });
const context = { task: "test", agentId: "agent-1" };
const result = await myPhase(state, context).pipe(
Effect.provide(mockLLMLayer),
Effect.runPromise,
);
expect(result.messages).toEqual(state.messages);
}, 15000);
});
Critical: Do NOT Touch
kernel/loop/runner.ts main loop — extend via phases, not inline logic
context-engine.ts: buildStaticContext is LIVE (the static system-prompt builder, called from context/prompt-sections-default.ts) — do NOT treat it as dead. Only buildDynamicContext was removed (Apr 2026). Earlier versions of this skill wrongly listed buildStaticContext as disabled.
state.messages[] via direct mutation — return new state object from phases