-
Notifications
You must be signed in to change notification settings - Fork 4
Engine
src/engine/ is the worker-subprocess engine boundary. It owns the runtime that executes one
dispatched worker run: it builds the pi-agent-core Agent for a worker, installs the unified loop
guard as a before_tool middleware hook, composes the admitted tool surface, and forwards every
AgentEvent to the worker entry, which serializes events to NDJSON stdout. The same module family
also hosts the engine's artifact contract with the session domain (session.ts) and the per-provider
request payload patches used to force text-only rounds, require one named tool, or attach a JSON-schema
response constraint.
The boundary is deliberately thin. src/engine/types.ts re-exports the engine-boundary types
(EngineModel, AgentMessage, AgentEvent, Usage) so that domains never import pi-mono packages
directly; the comment in types.ts states that importing engine package types from anywhere else
violates the engine boundary.
createLoopGuardRegistration(options) returns a middleware hook registration that carries the
loop-detector state for one run. It is registered on both the worker registry (worker-runtime.ts)
and the orchestrator registry (src/entry/orchestrator.ts), so admission and repetition detection share
one seam in both processes.
The guard's before_tool branch (evaluate) applies, in order:
-
Turn loop-block budget.
INTERACTIVE_LOOP_BLOCK_BUDGET = 2for the orchestrator; the worker derives its own budget fromworkerLoopBlockBudget(toolCalls). Once the budget is exhausted the guard emits alock_toolseffect for the rest of the turn and firesonSynthesisLockout. -
Repeat-call detector. A turn-scoped key combines
turnKey, a mutation epoch, and the call fingerprint (callFingerprintmetadata). A repeated identical call inside the window is blocked as a loop.loopBlockBaseReasonreports the count and prior successes. -
Result stagnation.
RESULT_STAGNATION_THRESHOLD = 3: when the incoming call has the same shape (size args ignored) as a streak of calls whose results hashed identical, the attempt is blocked. -
Worker budget phase.
workerToolCallBudgetenforces the soft limit (toolCallSoftLimit), the hard lifetime cap (toolCallCap), and the read reserve. TheonSynthesisLockoutcallback flips the runtime'ssynthesisToolLockso the next model round is forced text-only.
The guard exposes extendWorkerToolCallPhase(budget) for the result-contract revision window: it reopens
the budget only when the run has not already hit the hard cap or the repeated-call lock. The
resolveDeliveryTools helper returns write/edit (plus code_nav for the orientation product) as
the tools that stay admitted in the reserve window, because an agent whose product is files must still be
able to write them in its last calls; isReserveAdmittedTool returns true for read or a delivery tool.
sanitizeLockedSynthesisMessage, stripDeadToolCallMarkup, isLockedSynthesisFallbackOnly,
lockedSynthesisSystemPrompt, and lockedSynthesisRepromptMessages implement the synthesis-locked
message lifecycle: they strip tool-call markup from a locked reply, detect a markup-only fallback, and
compose the one-shot re-prompt that asks for the required terminal result format.
startWorkerRun(input, emit) is the single entry point. It takes a WorkerRunInput (a TargetDescriptor
-
RuntimeDescriptor+ wire model id, not a provider/model pair) and returns aWorkerRunHandlewith a promise, anabort(), an optionalsteer(), and an optionalresolvePermission().
The runtime branches before any model call:
-
Claude SDK — when
input.runtime.id === "claude-sdk", it delegates tostartClaudeSdkWorkerRuninsrc/engine/claude/sdk-runtime.ts. -
External CLI — when
input.runtime.kind === "subprocess", it delegates tostartExternalCliWorkerRuninsrc/engine/external-cli/connectors.ts, which dispatches by runtime id to the managed connectors (claude-code,codex-cli,opencode-cli,pi-cli,antigravity-code). -
Native pi-agent — otherwise it builds the pi-agent-core
Agentdirectly. It first registers the Clio API providers (the worker is a fresh process), reads the workspace's trusted settings layers to project output limits and guardrail policy, synthesizes theEngineModel, constructs the safety contract viacreateWorkerSafety, registers the loop guard on the tool registry, and subscribes an event sink that forwards everyAgentEventtoemit.
The native path composes the admitted surface from input.allowedTools, applies the skill policy, and
builds the tool set with resolveAgentTools. It wires the synthesis lock through onPayload so that a
locked round calls patchWorkerRequestPayload with toolSurfaceLocked: true. Result-contract repair is
bounded by RESULT_CONTRACT_REPAIR_LIMIT, and observed read spans (observedReadRanges, observedGrepLines)
are recorded from the tool telemetry to ground the terminal result.
The worker's permission posture is non-stall by design: onPermission defaults to "deny", "fail"
aborts the run, and "escalate" parks the call and hands the decision to the operator over the
event/stdin channels with a bounded timeout. workerPermissionCacheKey produces a byte-stable key from the
exact call and permission conditions so an identical re-issued call gets the same remembered answer
without a new escalation card.
patchWorkerRequestPayload(payload, model, options) composes all worker-owned request mutations over one
payload in a stable order. The individual patches are dialect-aware:
-
patchToolSurfaceLockedPayloadremoves the tool surface entirely for a synthesis-locked round. This is the hard variant used for a worker's locked rounds (the loop-guard lockout and the terminal result-contract repair), becausetool_choice: "none"alone is not enough on llama.cpp: the chat template still renders the tool schema, so a local model that calls a tool anyway hands its markup back as content. Anthropic keeps thetool_choiceknob instead, because its API rejects a history carryingtool_useblocks unless tools are defined. -
patchToolChoiceNonePayloadis the softer variant for a middleware lock or the interactive synthesis lockout: it setstool_choice: { type: "none" }on Anthropic andtool_choice: "none"elsewhere, leaving the schema surface byte-stable. -
patchToolChoiceNamedPayloadrequires one named tool for the next round, preserving the full schema surface on Anthropic (it disables adaptive thinking for that request only) and narrowing to the single definition on the others. -
patchTerminalToolPayloadexposes one handoff tool, never the work surface, for a terminal protocol round. -
patchProviderThinkingPayloadsets the OpenAI Responses reasoning summary to"concise"or"detailed"based on the effective thinking level; Anthropic thinking is pi-owned and left untouched. -
patchResponseSchemaPayloadForDialectattaches the admitted runtime's JSON-schema constraint in the dialect the runtime takes (llamacpp-json-objector a strictjson_schemaresponse format).
supportsNamedToolChoice(api) lists the APIs whose named-tool request dialect is implemented. The
WorkerPayloadPatchOptions interface carries runtimeId, thinkingLevel, responseSchema,
toolChoiceNone, toolChoiceName, toolSurfaceLocked, and terminalToolName.
The Claude SDK path has no registry park loop and cannot mediate per-tool calls through the native safety
net, so it maps Claude preset tool names to Clio builtins and evaluates each call itself.
emitClaudeToolPermissionDecision(input) is the mediation seam, called from both the SDK's canUseTool
callback and the PreToolUse hook so that one logical decision is shared across the pair
(decideClaudeSdkToolUseOnce).
CLAUDE_TOOL_TO_CLIO is the single source of truth mapping Claude preset names to Clio builtins:
Bash→bash, Read/NotebookRead→read, Edit/MultiEdit→edit, Write→write,
Grep→grep, Glob→find, LS/Ls→ls, WebFetch/WebSearch→web_fetch, Task→dispatch,
TodoWrite→tasks. mapClaudeToolCall performs the forward mapping per call, translating argument shapes
(pathArgs, searchArgs, commandArgs) into the Clio builtin's expected keys. claudeToolsOutsideProfile
reverses the map to produce the SDK's disallowedTools list — the Claude preset tools whose Clio builtin is
not in the worker's allowed surface.
evaluateClaudeToolPermission applies the gates in order: (1) the budget gate's attempt(), (2) the
admitted-surface gate — an out-of-profile mapped tool is denied before the safety net runs, (3) the safety
net's evaluate, (4) the read-only dispatch restriction, and (5) the autonomy mapping via mapAutonomy at
DEFAULT_AUTONYM_LEVEL, with the budget gate's admit() running on the allow path. emitClaudeToolPermissionDecision
wraps the decision with telemetry: it emits clio_coder_tool_start and clio_coder_tool_finish (the same
event shapes the native registry uses) and, on a permission-required decision, emits a
clio_coder_permission_resolved event reflecting the run's onPermission posture.
startClaudeSdkWorkerRun(input, emit) runs the worker through the @anthropic-ai/claude-agent-sdk
optional dependency, loaded lazily from ./sdk-module.ts at run time. It collapses the escalate posture to
the non-stall deny fallback because the SDK path has neither the registry park loop nor an operator on
the worker's stdin. The permission gate is built from createWorkerSafety, the admitted tool set, and a
ClaudeWorkerBudgetGate created by createClaudeWorkerBudgetGate(budget, onBoundary, onHardCap, deliveryTools).
createClaudeWorkerBudgetGate is the canonical call counter shared by the SDK's PreToolUse/canUseTool
mediation seam. Its attempt() counts one logical SDK tool attempt and applies the hard-cap check; its
admit() rechecks phase policy at the execution boundary and increments the admitted count, locking the
run at the soft budget when the agent has no delivery tools. phaseReached() reports the locked state,
which the PostToolBatch hook reads to end the tool phase. permissionResultForDecision converts a
ClaudeToolPermissionDecision into the SDK's PermissionResult, honoring the fail posture by setting
interrupt: true.
The run forwards SDK messages to emit: stream_event messages become message_update text deltas via
emitTextDelta, assistant messages update the running text and model, and the result message
determines the final stopReason. Usage is normalized from the SDK's cost fields by normalizeUsage.
startClaudeCodeWorkerRun(input, emit) spawns the claude CLI (CLAUDE_BINARY = "claude") as a
one-shot subprocess. buildClaudeCodeArgs sets the permission mode: read-only dispatches run in
--permission-mode plan with the read-only tool allowlist READ_ONLY_CLAUDE_TOOLS = ["Read", "Grep", "Glob", "LS", "WebFetch", "WebSearch"]; otherwise they run in --permission-mode acceptEdits. The prompt is
fed on stdin (-p reads it from stdin when no positional prompt is given), so the prompt never appears in
argv; the system prompt rides on --append-system-prompt. buildClaudeCodePrompt joins the dynamic prompt
messages and the task. readJsonLines parses the stream-json output: system records set the model,
result records capture usage, and assistant/delta records feed emitTextDelta.
startAntigravityWorkerRun(input, emit, dependencies) spawns the official agy CLI
(ANTIGRAVITY_BINARY = "agy"). buildAgyArgs enforces the tool profile via
assertToolProfileEnforceable(input.toolProfile, "antigravity-code") and selects the peer's permission mode
through antigravitySubprocessConfig(readOnly): read-only dispatches pass --mode plan --sandbox
(external mode plan+sandbox), others pass --mode accept-edits. The run uses --input-format stream-json --output-format stream-json --disable-slash-commands.
buildAgyStdinLine emits a single one-turn stream-json stdin record
({ event: "user", message: { content } }), bounded by ANTIGRAVITY_MAX_PROMPT_BYTES (4 MiB). The
byte caps are explicit: ANTIGRAVITY_MAX_STREAM_LINE_BYTES = 1 MiB,
ANTIGRAVITY_MAX_STREAM_BYTES = 8 MiB, ANTIGRAVITY_MAX_RESPONSE_BYTES = 4 MiB,
ANTIGRAVITY_MAX_CONVERSATION_ID_BYTES = 4 KiB. parseAntigravityStreamLine parses the peer's
newline-delimited events into init / step_update (text delta) / result / other. readStream drives a
state machine that flags protocol violations (duplicate init, text before init, event after terminal
result) in protocolDiagnostics, and resultDiagnostic composes the terminal error from spawn error,
stream error, protocol diagnostics, and the peer's status field.
src/engine/external-cli/connectors.ts holds the registry of managed CLI connectors.
startExternalCliWorkerRun(input, emit) looks up the connector by input.runtime.id and delegates;
externalCliConnector(runtimeId) returns the matching connector or null. The connectors map to
startClaudeCodeWorkerRun (claude-code), startCodexCliWorkerRun (codex-cli), startJsonlCliRun
(opencode-cli, pi-cli), and startAntigravityWorkerRun (antigravity-code).
src/engine/codex/subprocess-runtime.ts spawns codex exec - (CODEX_BINARY = "codex"), which reads the
work order from stdin so prompt text never enters argv. buildCodexExecArgs selects the sandbox via
codexSubprocessPermissionConfig(readOnly) and passes --json --ephemeral --skip-git-repo-check. The run
parses the codex exec JSONL event stream into a state machine that tracks thread/turn start, terminal
completed/failed, and usage.
All subprocess runtimes share the helpers in src/engine/external-subprocess.ts: readBoundedLines
streams lines with a hard per-line and cumulative byte cap, readStderr tails the last 8 KiB of stderr,
and createProcessTreeTerminator kills the whole process tree on abort after a grace period.
session.ts is the engine's artifact contract with the session domain. It is pure Node (fs, path, crypto)
and imports no pi-mono packages. createSession / openSession / resumeSession manage the on-disk layout
under clioStateDir(): sessions/<cwdHash>/<sessionId>/ holds meta.json, current.jsonl (append-only
structured entries), and tree.json (turn tree nodes). CURRENT_SESSION_FORMAT_VERSION = 5 stamps every new
session; readers reject earlier versions and future versions.
Append-only writes use an O_APPEND fd held for the writer's lifetime; writeAll/appendAll loop over
writeSync to guarantee complete byte writes, and appendAll rolls back a failed append with
ftruncateSync so a retry cannot fuse onto a partial record. fsync is debounced (APPEND_FSYNC_DEBOUNCE_MS)
after appends and forced on persistTree (checkpoint) and close. writeJsonlFileAtomic rewrites
current.jsonl through a .tmp + fsync + rename, and recoverJsonlTargetIfMissing promotes a leftover
.tmp when the target is absent. Torn final lines from a crash are tolerated by the reader (skipped with a
warning). readSessionTailTurns reads only the last maxTurns non-header entries by scanning backward from
EOF, with cost bounded by maxTurns rather than file size.
-
Adding an external CLI runtime: register a new
ExternalCliConnectorin theCONNECTORSarray insrc/engine/external-cli/connectors.ts, pointing at astart...WorkerRunfunction that follows the spawn/parse/emit pattern of the existing runtimes. The runtime id must match a runtime descriptor'sid. -
Adding a Claude preset tool to the safety map: add the mapping to
CLAUDE_TOOL_TO_CLIOinsrc/engine/claude/tool-safety.tsand the corresponding case to themapClaudeToolCallswitch, keeping the two in lockstep as the comment requires.claudeToolsOutsideProfilepicks up the new entry automatically for thedisallowedToolslist. -
Adding a provider payload dialect: add the api string to
supportsNamedToolChoiceand a branch in the relevant patcher (patchToolChoiceNamedPayload,patchToolChoiceNonePayload) insrc/engine/provider-payload.ts. -
Adding a loop-guard budget knob: the guard reads
toolCallCap,toolCallSoftLimit,toolCallReserve,toolCallSoftReadReserve,deliveryTools, andturnBlockBudgetfromCreateLoopGuardRegistrationOptions; the worker computes these fromWorkerBudgetand passes them when registering the guard inworker-runtime.ts.
-
tests/contracts/read-only-research-synthesis-budget.test.ts— the loop-guard contract test. It builds acreateLoopGuardRegistrationwithtoolCallSoftLimit: 36,toolCallCap: 150, andturnSynthesisLockout: true, then asserts that 36Readcalls are all admitted, that the synthesis lock flips only on the 36th call, and that the 37th call is blocked. It also checks the budget envelope for acoderrecipe stays advisory and the receipt integrity for native scout/provenance runs. -
tests/contracts/worker-boundary.test.ts— exercisesworkerPermissionCacheKeyfromworker-runtime.ts: the same key for reordered args (command/cwd), a different answer for a different axis, action class, or args; and the strict recipe/tool-envelope/capability/admission boundary. -
tests/contracts/read-scope.test.ts— the Claude tool-safety test. Its"uses default permission mapping for Claude SDK and ACP peers and enforces read-only"case callsemitClaudeToolPermissionDecisionwithsafety: createWorkerSafety({ cwd: root })and asserts aReadof an inside path isallow, aReadof an outside path isdeny, and search tools that reach outside the workspace are denied under the default permission mapping. -
tests/contracts/antigravity-subprocess.test.ts— exercises the Antigravity subprocess arg building, stdin line, stream parsing, and byte caps. -
tests/extended/synthesis-lock.test.ts— exercises the loop-guard synthesis lock and the payload patches. It assertspatchWorkerRequestPayloadwithtoolSurfaceLocked: trueremovestoolsandtool_choicefor allamacppmodel but keepstoolsand setstool_choice: { type: "none" }for ananthropic-messagesmodel, and thatsanitizeLockedSynthesisMessage/isLockedSynthesisFallbackOnlydetect a markup-only locked reply andlockedSynthesisRepromptMessagesshapes one paired re-prompt.
- The loop guard and the worker share the synthesis-lock contract: the guard's
onSynthesisLockoutcallback flipsworker-runtime.ts'ssynthesisToolLock, whichonPayloadreads to passtoolSurfaceLocked: truetopatchWorkerRequestPayload. A change that locks the tools in one place but not the payload patch leaves the model able to call tools on a locked round. -
patchToolSurfaceLockedPayloadandpatchToolChoiceNonePayloadare deliberately different: the former removes the tool surface (hard lock, for synthesis), the latter setstool_choice: "none"(soft lock, for middleware). They must stay distinct; conflating them breaks the interactive synthesis lockout on llama.cpp. - The Claude SDK permission path must keep
decideClaudeSdkToolUseOnceas the single decision point, called from bothbuildCanUseToolandbuildPreToolUseHook, or the SDK hook/callback pair can diverge on the same tool use. - The subprocess runtimes enforce the tool profile via
assertToolProfileEnforceable(input.toolProfile, "antigravity-code" | "claude-code" | "codex-cli")in their arg builders; a new runtime must call the matching assertion or a narrowing profile is silently ignored. - The session ledger's
O_APPENDwriter and the.tmprecovery rely on the torn-tail tolerance in the reader; changingwriteJsonlFileAtomicorrecoverJsonlTargetIfMissingmust preserve that the reader skips exactly one invalid final line with a warning. -
session.tsis pure Node and imports no pi-mono packages; keep it that way. It is the engine's artifact contract with the session domain. -
CLAUDE_TOOL_TO_CLIOand themapClaudeToolCallswitch must stay in lockstep; the former feeds the reverseclaudeToolsOutsideProfilelist and the latter feeds the forward mapping, so a tool added to one but not the other leaves a gap in either the mediation or the disallow list.
Source and generation metadata
title: "Engine"
summary: "The worker-subprocess engine boundary: the unified loop guard, the pi-agent worker runtime with its Claude SDK and external-CLI branches, per-provider request payload patches, the Claude tool-safety mediator, and the session JSONL ledger."
sources:
- "src/engine/loop-guard.ts"
- "src/engine/worker-runtime.ts"
- "src/engine/claude/sdk-runtime.ts"
- "src/engine/claude/tool-safety.ts"
- "src/engine/antigravity/subprocess-runtime.ts"
- "src/engine/codex/subprocess-runtime.ts"
- "src/engine/provider-payload.ts"
- "src/engine/session.ts"
symbols:
- "createLoopGuardRegistration"
- "startWorkerRun"
- "startClaudeSdkWorkerRun"
- "createClaudeWorkerBudgetGate"
- "emitClaudeToolPermissionDecision"
- "startAntigravityWorkerRun"
- "patchWorkerRequestPayload"
tests:
- "tests/contracts/read-only-research-synthesis-budget.test.ts"
- "tests/contracts/worker-boundary.test.ts"
- "tests/contracts/read-scope.test.ts"
- "tests/contracts/antigravity-subprocess.test.ts"
- "tests/extended/synthesis-lock.test.ts"
invariants:
- "A worker run's tool calls are bounded by a hard lifetime cap in the loop guard, so a degenerate model cannot burn the run by spamming distinct calls past the ceiling."
- "A synthesis-locked worker round ships no tool surface for non-Anthropic APIs, so a model that calls a tool anyway returns markup as text that is stripped to a fallback notice."
- "Every Claude SDK tool call is mapped to a Clio builtin and denied before the safety net runs if that builtin is outside the worker's admitted surface."
- "A Claude SDK run collapses the escalate permission posture to the non-stall deny fallback because the SDK path has no registry park loop."
validate:
- "node --import tsx --test tests/contracts/read-only-research-synthesis-budget.test.ts tests/contracts/worker-boundary.test.ts"Clio Coder · Repository · Website · Documentation
Wiki v0.1 · Developing implementation reference · Source snapshot: 657dce13d. Authored architecture documents define the product contracts.
- Clio Coder GUI Client
- apps / clio-coder-gui
- Apps clio coder gui server
- Apps clio coder gui tests
- apps
- Architecture
- Command-line surfaces
- Core
- Domains agents
- Config Domain
- Context Domain
- Dispatch domain
- Domains evidence
- Domains extensions
- Domains gateway
- domains
- Domains interop
- Domains lifecycle
- Domains memory
- Middleware Domain
- Domains mux
- Domains observability
- Domains plugins
- Prompt Compiler
- Domains providers
- Domains quota
- Domains resources
- Domains safety
- Domains scheduling
- Domains session
- Vendored Tool Registry and Resolution
- Engine
- Engine acp
- Engine apis
- engine
- Entry point
- Interactive
- interactive
- Interactive overlays
- Interactive renderers
- clio-coder wiki
- Scripts
- Contract tests
- Tests extended
- tests
- Tools
- Tools data
- tools
- Tools verify
- Worker runtime