31 KiB
05a — Subsystem: Agent Runtime
Purpose. The agent runtime is the engine that turns one user message into one agent response. It assembles a layered system prompt (identity + memory + behavioral rules + persona + response scaffold), runs a tool-calling loop against an OpenAI-compatible LLM endpoint (LiteLLM), executes each tool through an explicit middleware chain (governance, hooks, injection-scan, loop-guard), and applies completion-time "gates" that force verification, real file writes, and skill distillation before accepting a final answer. This document is the contract a frontend needs to understand what POST /api/chat actually does between request and SSE response — every claim below is grounded in the code under packages/agent/src/.
Scope note: this section documents the agent runtime (the loop, prompt, personas, tools, gates, cost). The HTTP surface (
/api/chat, agent-run routes, SSE event names) is the API subsystem's job; this section only shows where the runtime plugs intopackages/server/src/local/routes/chat.tsso the wiring is legible.
1. The mental model in one paragraph
A turn enters at runAgentLoop(config) in agent-loop.ts. It builds a messages array ([{role:'system', content: systemPrompt}, ...history]), converts ToolDefinition[] to OpenAI tools schema, then loops up to maxTurns times. Each iteration POSTs to ${litellmUrl}/chat/completions. If the model returns no tool calls, the loop runs the completion gates (maybeFireCompletionGate) — and if none fire, returns the final AgentResponse. If the model returns tool calls, each is executed through executeToolCall (the middleware chain), results are pushed back as role:'tool' messages, and the loop continues. The system prompt itself is built by the Orchestrator (buildSystemPrompt + recallMemory, optionally the tier-adaptive PromptAssembler) and decorated by the route with persona, profile, runtime facts, behavioral spec, and workspace context.
2. Files in this subsystem
| File | Role |
|---|---|
packages/agent/src/agent-loop.ts |
runAgentLoop() — the core tool-calling loop. Re-exports retrieval-loop entry points. |
packages/agent/src/orchestrator.ts |
Orchestrator class — buildSystemPrompt(), recallMemory(), buildAssembledPrompt(), autoSaveFromExchange(), memory-layer accessors. |
packages/agent/src/tool-executor.ts |
executeToolCall() — 11-step per-tool middleware chain. |
packages/agent/src/loop-gates.ts |
maybeFireCompletionGate() + GateState — the D3/D4/D1 completion gates. |
packages/agent/src/loop-guard.ts |
LoopGuard — duplicate/oscillation tool-call detection. |
packages/agent/src/retry-policy.ts |
handleNonOkResponse() / handleNetworkError() — 429 / 5xx / network backoff. |
packages/agent/src/verification-gate.ts |
assertsUnverifiedCompletion() + VERIFICATION_GATE_DIRECTIVE (D3). |
packages/agent/src/skill-distillation.ts |
planSkillDistillation() / shouldDistillSkill() (D1). |
packages/agent/src/personas.ts |
AgentPersona interface + composePersonaPrompt(), getPersona(), listPersonas(). |
packages/agent/src/persona-data.ts |
PERSONAS array — 22 built-in personas (pure data). |
packages/agent/src/tool-filter.ts |
filterToolsForContext(), filterAvailableTools(), filterOfflineTools(). |
packages/agent/src/prompt-assembler.ts |
PromptAssembler — sixth-layer tier-adaptive prompt (feature-flagged). |
packages/agent/src/context-loader.ts |
loadRecentContext() / loadRecentContextFrames() + ContextFrames type. |
packages/agent/src/task-shape.ts |
detectTaskShape() — pure heuristic task classifier. |
packages/agent/src/model-tier.ts |
tierForModel() — maps a model id to small / mid / frontier. |
packages/agent/src/behavioral-spec.ts |
BEHAVIORAL_SPEC (v3.0) + buildActiveBehavioralSpec() + COMPACTION_PROMPT. |
packages/agent/src/cost-tracker.ts |
CostTracker + DEFAULT_MODEL_PRICING + BudgetExceededError. |
packages/agent/src/result-formatter.ts |
formatCombinedResult() — renders combined-retrieval results to markdown. |
packages/agent/src/turn-context.ts |
generateTurnId() / logTurnEvent() — per-turn trace correlation (H-AUDIT-1). |
3. The loop — runAgentLoop(config) (agent-loop.ts)
3.1 Input contract (AgentLoopConfig)
| Field | Type | Notes |
|---|---|---|
litellmUrl |
string |
Base URL; loop POSTs to ${litellmUrl}/chat/completions. |
litellmApiKey |
string |
Sent as Authorization: Bearer …. |
model |
string |
Model id (passed through to LiteLLM). |
systemPrompt |
string |
Pre-built (by Orchestrator + route); becomes the first system message. |
tools |
ToolDefinition[] |
Converted to OpenAI function-tool schema. |
messages |
Array<{role,content}> |
Conversation history (no system message). |
onToken? |
(token)=>void |
Streaming token callback (also receives retry-notice text). |
onToolUse? |
(name,input)=>void |
Fires before each tool executes. |
onToolResult? |
(name,input,result)=>void |
Fires after each tool, with sanitized result. |
maxTurns? |
number |
Default 10. The chat route sets 200 for persistent agents. |
stream? |
boolean |
Default false. When true, sends stream:true + stream_options.include_usage. |
fetch? |
typeof fetch |
Injectable for tests. |
hooks? |
HookRegistry |
pre:tool / post:tool / pre:memory-write / post:memory-write. |
capabilityRouter? |
CapabilityRouter |
Resolves unknown tool names to alternatives. |
pluginTools? |
PluginToolProvider |
Merges active plugin tools into the toolset. |
maxTokenBudget? |
number |
Loop terminates gracefully when input+output exceeds this. |
signal? |
AbortSignal |
Client-disconnect; loop exits between turns and tears down in-flight fetch. |
governancePolicies? |
{blockedTools?, allowedSources?} |
blockedTools IS enforced; allowedSources is NOT yet enforced (logs a warning only — tools lack source-provenance metadata). |
traceRecording? |
{recorder, handle} |
Auto-wires trace callbacks (additive to user callbacks). |
turnId? |
string |
UUID v4 correlation key (H-AUDIT-1). |
verificationGate? |
boolean |
Default true (D3). |
skillDistillationGate? |
boolean |
Default true (D1). |
onSkillDistillationFire? |
(info)=>void |
AI-OS Phase 3 skill-diffusion observer; fires the moment D1 triggers. Errors swallowed. |
3.2 Output contract (AgentResponse)
interface AgentResponse {
content: string; // final answer
toolsUsed: string[]; // names of attempted tools
usage: { inputTokens: number; outputTokens: number };
}
AgentMessage is the internal message shape (note tool_calls and tool_call_id for OpenAI tool-call threading):
interface AgentMessage {
role: 'system' | 'user' | 'assistant' | 'tool';
content: string | null;
tool_calls?: Array<{ id: string; type: 'function'; function: { name: string; arguments: string } }>;
tool_call_id?: string;
}
3.3 Loop steps (per iteration, up to maxTurns)
- Abort check — if
signal.aborted, return"Agent loop aborted (client disconnected)." - Build request body —
{model, messages}; addtoolsif any; addstream/stream_optionsif streaming. Request signal =AbortSignal.any([clientSignal, AbortSignal.timeout(llmTimeoutMs)]). Timeout default 300_000 ms (WAGGLE_LLM_TIMEOUT_MS). - POST to
/chat/completions.- Network rejection →
handleNetworkError(): retry with exponential backoff (cap 30 s, max 3) without consuming a turn (turn--; continue), unless a genuine client-abort (re-throws). !response.ok→handleNonOkResponse(): 429 honorsRetry-After(cap 60 s); 502/503/504 exponential backoff (cap 30 s); anything else is fatal. Sameturn--retry semantics.
- Network rejection →
- Parse response — streaming via
parseChatCompletionStream(accumulates tokens intoallStreamedContent), or non-streaming viaresponse.json()(choices[0].message). Track per-turnprompt_tokens/completion_tokens. - Post-read abort check — return early if aborted mid-read.
- Accumulate tokens; reset retry counters on success.
- Token-budget check — if
maxTokenBudgetexceeded, return (preferringpreservedAnswerForDistillationif D1 already fired — issue #4). - No tool calls → run
maybeFireCompletionGate(§5). If a gate fired,continue. Otherwise emit content and returnAgentResponse(final content =preservedAnswerForDistillation ?? content). - Has tool calls → push the assistant message (content coerced to
''when null, for LiteLLM→Anthropic compatibility), then runexecuteToolCallfor each call (§4), pushing each result as arole:'tool'message. Continue loop. maxTurnsreached → return preserved answer, else accumulated content, else"Max tool turns reached (...)".
Re-exported from this file (implementation in
retrieval-agent-loop.ts):runSoloAgent,runRetrievalAgentLoop,runRetrievalAgentLoopWithRecovery, and their config/result types. The structured-action retrieval loop is a sibling pattern;agent-loop.tsis the canonical entry point for both.
4. Tool execution — executeToolCall() (tool-executor.ts)
Each tool call passes through an ordered 11-step middleware chain. The order around sanitization (steps 8→9→10→11) is a load-bearing invariant — sanitized output is what flows into both model context AND every observer (audit/telemetry/team-sync/UI).
| Step | Action | Early-return? |
|---|---|---|
| 1 | JSON.parse arguments |
Yes — invalid JSON → error result, countedAsUsed:false |
| 2 | onToolUse(name, args) callback |
— |
| 3 | Governance blockedTools check |
Yes — fires onToolResult with a policy message, countedAsUsed:false |
| 4 | pre:tool hook |
Yes — cancel → [BLOCKED] reason |
| 5 | pre:memory-write hook (only save_memory) |
Yes — cancel → [BLOCKED] Memory write blocked |
| 6 | LoopGuard.check |
Produces an error result (not early-return) if a loop is detected |
| 7 | Execute the tool (or capability-router fallback, or unknown-tool error). Wrapped in try/catch. | — |
| 8 | scanForInjection(result, 'tool_output') — replaces output with [SECURITY] … sanitized. if unsafe |
— |
| 9 | onToolResult(name, args, sanitizedResult) |
— |
| 10 | post:memory-write hook (only save_memory) |
— |
| 11 | post:tool hook |
— |
countedAsUsed is true only when the tool was actually attempted (success OR thrown-inside error) — pre-execution rejections (parse error, governance block, hook cancel) do not count toward toolsUsed.
Unknown tool with a capabilityRouter: returns a list of alternative routes ([source] name: description (available|not wired yet)) and, if any are missing and acquire_capability is on offer, appends a tip to use it.
LoopGuard (loop-guard.ts)
- Hashes
sha256(toolName + ':' + JSON.stringify(args)). - Consecutive repeats: flags after
maxRepeats(default 3) identical calls in a row. - Oscillation window: rolling window of
windowSize(default 10); flags if the same hash appearswindowThreshold(default 4) times. Returnsfalse→ executor produces a "Loop detected" error instead of running the tool.
5. Completion gates — maybeFireCompletionGate() (loop-gates.ts)
Gates run only on the no-tool-calls (final-answer) branch. At most one fires per call; each is one-shot per runAgentLoop invocation via GateState. When a gate fires, it pushes the assistant content + a corrective user directive into messages and returns fired:true, so the loop continues for one more model turn.
GateState flags: verificationCorrectionUsed, writeCorrectionUsed, skillDistillationUsed, and preservedAnswerForDistillation (the real user answer captured at D1 fire time, surfaced in the final return instead of the skill-summary turn).
| Gate | Order | Fires when | Directive |
|---|---|---|---|
| D3 — Verification | 1st | assertsUnverifiedCompletion(content, toolsUsed): content asserts "tests pass / build succeeds / it works / verified" but no verification-class tool (test|build|run|verif|lint|tsc|pytest|jest|vitest|exec|bash|compile|spec) was used |
VERIFICATION_GATE_DIRECTIVE — "run the check now and quote real output, OR label UNVERIFIED" |
| D4 — Phantom write | 2nd | writeToolAvailable (a write_file/edit_file tool is on offer) AND assertsPhantomWrite(content, toolsUsed): past-tense "created/wrote/saved the file" with no write-class tool (write_file/edit_file/generate_docx) used |
WRITE_GATE_DIRECTIVE — "call write_file NOW with the full contents, or say you didn't intend to" |
| D1 — Skill distillation | 3rd | planSkillDistillation(toolsUsed, content) returns non-null: ≥5 tool calls AND not a self-incapacity/refusal turn (R2 sign-gate) |
Directive instructing the model to call create_skill next turn (after search_skills). Also invokes onSkillDistillationFire (errors swallowed). |
writeToolAvailable is computed once in the loop: toolMap.has('write_file') || toolMap.has('edit_file').
6. The Orchestrator — prompt + memory (orchestrator.ts)
Orchestrator owns the memory layers and produces the prompt pieces. Constructed with {db, embedder, apiKey?, model?, mode?, version?, skills?}. It instantiates IdentityLayer, AwarenessLayer, FrameStore, SessionStore, HybridSearch, KnowledgeGraph, ImprovementSignalStore, a CognifyPipeline, and the mind tools (createMindTools).
It can hold a second mind: setWorkspaceMind(workspaceDb) activates workspace-specific frames/search/knowledge/cognify alongside the personal mind. Identity always stays personal. getMemoryStats() returns {frameCount, sessionCount, entityCount} summed across both.
6.1 buildSystemPrompt(): string
Joins three sections (filtering empties):
- Identity — cached (
cachedSection), keyed on the full identity JSON.# Identity\n+identity.toContext(). - Self-awareness — uncached (runtime).
buildSelfAwareness(caps)overAgentCapabilities(tools list, skills, model, memory stats, mode, version, actionable awareness). Defers signal marking tocommitSurfacedSignals(). - Preloaded context —
loadRecentContext()→# Context From Your Memory\n….
6.2 recallMemory(query, limit=10, opts?): Promise<{text, count, recalled?}>
Automatic per-turn memory recall. Key behaviors:
- Catch-up intent detection — regexes like
catch me up,where are we,what's next,summariz,remind me. On a hit with an active workspace, fetches importance-ranked frames (critical/important orDecision%/%decided%) by SQL instead of semantic search, deduped by frame id. - Authoritative-recall filter — drops frames whose importance is
temporaryordeprecated(R2 sign-gate: self-incapacity frames are storedtemporaryso they don't re-enter as authoritative). - Optional
scoreFloor(PromptAssembler opt-in) — filters byfinalScore ?? score. - Injection scan — joins all recall lines and runs
scanForInjection(…, 'tool_output'); on a hit, blocks the entire recall (returns empty) and logs a warning. - Provenance-honest framing — the returned
textincludes strict instructions: attribute saved memory explicitly, never claim continuity ("welcome back") on a first message, never confabulate numbers/dates/names not present verbatim. - On error — returns
"[Memory recall temporarily unavailable. Proceed without prior context.]"(visible, not silent — silent empties train the model to confabulate).
RecallOptions: { profile?: ScoringProfile (default 'balanced'), scoreFloor?, tier?, turnId? }.
6.3 buildAssembledPrompt(query, persona, opts) (feature-flagged)
Used only when isEnabled('PROMPT_ASSEMBLER'). Computes tierForModel(model), calls buildSystemPrompt() for the core, loads typed ContextFrames, runs raw HybridSearch (limit 10, balanced) on personal + workspace, runs ONE injection scan (assembler trusts scanSafe and must not re-scan), and hands a RecalledMemory to PromptAssembler.assemble().
6.4 Post-response write-back
autoSaveFromExchange(userMsg, assistantMsg) → runPatternWriteBack(...): regex-driven heuristic save (preferences/corrections/style → personal mind; decisions/work-output → workspace or personal). commitSurfacedSignals() marks awareness signals as surfaced after a successful model call.
7. Personas (personas.ts + persona-data.ts)
7.1 AgentPersona interface (all fields)
| Field | Type | Meaning |
|---|---|---|
id |
string |
Stable persona id. |
name |
string |
Display name. |
description |
string |
Short role description. |
icon |
string |
Emoji icon. |
systemPrompt |
string |
Role instructions appended after the core prompt. |
modelPreference |
string |
Suggested model (user-overridable). All built-ins: claude-sonnet-4-6. |
tools |
string[] |
Tool subset the persona uses (allowlist). |
workspaceAffinity |
string[] |
Workspace types this persona suits. |
suggestedCommands |
string[] |
Slash commands to suggest. |
defaultWorkflow |
string | null |
Auto-invoke workflow template. |
disallowedTools? |
string[] |
Denylist — overrides tools[] on conflict. |
failurePatterns? |
string[] |
Documented failure modes (tooltip). |
isReadOnly? |
boolean |
true = no write tools ever. Drives whether WRITE_DISCIPLINE is appended. |
tagline? |
string |
One-sentence picker-hover line. |
bestFor? |
string[] |
3 example tasks (user-facing). |
wontDo? |
string |
Hard boundary statement. |
suggestedSkills? |
string[] |
Marketplace skill names. |
suggestedConnectors? |
string[] |
Connector ids. |
suggestedMcpServers? |
string[] |
MCP server names. |
7.2 The 22 built-in personas (PERSONAS array)
CLAUDE.md describes "13 + 4". The actual
persona-data.tsarray contains 22 personas: the original 8, the "Mega Test V2" 5, the universal/orchestration 4, and 5 more domain personas. Listed exactly as found:
| # | id |
name |
icon | readonly | disallowedTools |
defaultWorkflow |
|---|---|---|---|---|---|---|
| 1 | researcher |
Researcher | 🔬 | — | — | research-team |
| 2 | writer |
Writer | ✍️ | — | bash, git_commit, git_push, spawn_agent | null |
| 3 | analyst |
Analyst | 📊 | — | — | null |
| 4 | coder |
Coder | 💻 | — | [] |
null |
| 5 | project-manager |
Project Manager | 📋 | — | git_commit, git_push, bash | plan-execute |
| 6 | executive-assistant |
Executive Assistant | 📧 | — | bash, git_commit, git_push, spawn_agent | null |
| 7 | sales-rep |
Sales Rep | 🎯 | — | git_commit, git_push, bash, spawn_agent | research-team |
| 8 | marketer |
Marketer | 📢 | — | bash, git_commit, git_push, spawn_agent | null |
| 9 | product-manager-senior |
Senior PM | 🗺️ | — | bash, git_commit, git_push | plan-execute |
| 10 | hr-manager |
HR Manager | 👥 | — | bash, git_commit, git_push, spawn_agent | null |
| 11 | legal-professional |
Legal Counsel | ⚖️ | — | bash, git_commit, git_push, spawn_agent | null |
| 12 | finance-owner |
Business Finance | 💰 | — | bash, git_commit, git_push, spawn_agent | null |
| 13 | consultant |
Strategy Consultant | 🎯 | — | [] |
research-team |
| 14 | general-purpose |
General Purpose | 🧠 | false |
[] |
null |
| 15 | planner |
Planner | 🗂️ | true |
write_file, edit_file, git_commit, git_push, git_merge, generate_docx, install_capability, spawn_agent, execute_step, save_memory | null |
| 16 | verifier |
Verifier | 🔍 | true |
write_file, edit_file, git_commit, git_push, git_merge, generate_docx, save_memory, install_capability, spawn_agent, execute_step | null |
| 17 | coordinator |
Coordinator | 🎛️ | false |
read_file, write_file, edit_file, bash, web_search, web_fetch, search_files, search_content, generate_docx, git_* | coordinator |
| 18 | support-agent |
Customer Support | 🎧 | — | — | null |
| 19 | ops-manager |
Operations Manager | ⚙️ | — | — | plan-execute |
| 20 | data-engineer |
Data Engineer | 📈 | — | — | null |
| 21 | recruiter |
Recruiter | 🤝 | — | — | null |
| 22 | creative-director |
Creative Director | 🎨 | — | — | review-pair |
The 4 "universal + orchestration tier" personas (#14–17) match CLAUDE.md's "Target (17 personas — add 4)" goal: general-purpose, planner, verifier, coordinator. coordinator requires FEATURE_FLAGS.COORDINATOR_MODE (gates spawn_agent at runtime).
7.3 composePersonaPrompt(corePrompt, persona, maxChars=32000, workspaceTone?)
Builds the final system prompt around the persona:
- Always appends
DOCX_HINTto the core prompt. - If
workspaceTonematches aTONE_INSTRUCTIONSkey (professional/casual/technical/legal/marketing), appends a## Communication Tonesection. - If
personais null → returns base prompt. - Writable personas (
isReadOnlyfalsy) get theWRITE_DISCIPLINEblock appended to theirsystemPrompt— an explicit "you MUST call write_file; text in your reply is NOT a file" directive that counters phantom-write narration. Read-only personas never get it. - Combined with
SEPARATOR(\n\n---\n\n); persona prompt truncated to fitmaxChars(~8000 tokens) if needed.
getPersona(id) → built-ins only. listPersonas() → built-ins + custom from disk (loadCustomPersonas(dataDir) after setPersonaDataDir).
8. Tool filtering (tool-filter.ts)
| Function | Behavior |
|---|---|
filterToolsForContext(tools, context, config?) |
config.enabled_tools (allowlist) wins; else context selects CODE_TOOLS or RESEARCH_TOOLS sets, or all for 'general'; then config.disabled_tools is subtracted. |
filterAvailableTools(tools) |
Runs each tool's checkAvailability(); excludes those returning false or throwing. Tools without the check are always included. |
filterOfflineTools(tools) / getOfflineCapableToolNames(tools) |
Keep only offlineCapable === true (PM-6, offline mode). |
ToolContext = 'general' | 'code' | 'research'. CODE_TOOLS = bash, read/write/edit_file, search_files, search_content, git_status/diff/log/commit. RESEARCH_TOOLS = web_search, web_fetch, search_memory, get_identity, get_awareness, query_knowledge, read_file, search_files, search_content, find_connector, list_connector_categories.
Note: persona-level allow/deny (
tools[]/disallowedTools[]) is enforced separately (CLAUDE.md references a plannedassembleToolPool).filterToolsForContextis the context-level filter; both can apply.
9. PromptAssembler — the sixth layer (prompt-assembler.ts)
Feature-flagged (WAGGLE_PROMPT_ASSEMBLER=1, default off). Produces a tier-adaptive, typed, scaffolded prompt. PromptAssembler.assemble(input, opts): AssembledPrompt.
interface AssembledPrompt {
system: string;
userPrefix: string; // currently ''
responseScaffold: string | null;
debug: { tier, taskShape, taskShapeConfidence, scaffoldApplied,
scaffoldStyle, sectionsIncluded, framesUsed, totalChars };
}
Tier drives frame count (FRAME_LIMITS): small:3, mid:6, frontier:10. Frames are deduped by id and sorted by IMPORTANCE_WEIGHT (critical:4 → deprecated:0) then recency.
Sections, in order (each tagged trimmable or never-trimmed):
Identity (never) → Persona (never) → State / I-frames (trim) → Recent changes / P+B-frames (trim first) → Active work (trim) → Personal preferences (never) → Recalled memory (only if recalled.scanSafe) → Response format (scaffold, gated). Truncation order when over maxSystemChars (default 32_000): Recent changes → Active work → State.
Scaffold gating (selectScaffold): only when taskShape.confidence ≥ confidenceThreshold (default 0.3) AND tier !== 'frontier'. Two matrices: COMPRESSION_SCAFFOLDS (v4 default — "say less") and EXPANSION_SCAFFOLDS (v5 — "say more in named sections", for dense instruction-tuned families like Gemma). draft and mixed shapes never get a scaffold at any tier; frontier always yields null.
tierForModel(model) (model-tier.ts): frontier = claude-opus*; small = gemma-4-, qwen3*, llama-3, llama-4; mid = claude-sonnet*, claude-haiku*, and unknown models default to mid.
10. Behavioral spec (behavioral-spec.ts)
BEHAVIORAL_SPEC (version '3.0') is the core agent rulebook, split into 5 named sections, with a backward-compatible .rules getter that concatenates them:
| Section | Content |
|---|---|
coreLoop |
The 5-step internal process: RECALL → ASSESS → ACT → LEARN → RESPOND, plus two === CRITICAL === blocks: Memory Conflict Protocol (never blindly accept a contradiction; surface both; update only after confirmation) and Verification Before Completion (state a checkable "done" condition, run the check this turn, quote real output). |
qualityRules |
Anti-hallucination discipline, structured-output guidance, context grounding, contextual professional disclaimers. |
behavioralRules |
Memory-first, tool intelligence, narration heuristics, error recovery, planning for complex tasks. |
workPatterns |
Drafting-from-context, decision compression, research-in-context recipes. |
intelligenceDefaults |
The # TOOLS reference — web, memory (two minds: workspace + personal), system, git, documents, connectors (148+ catalog), skills (incl. capability-acquisition + skill-distillation), sub-agents, planning, workflow composition. |
buildActiveBehavioralSpec(overrides) returns the same shape with per-section overrides applied (self-evolution deploy path); the chat route uses server.activeBehavioralSpec ?? BEHAVIORAL_SPEC. COMPACTION_PROMPT is exported for context-window summarization (8 required sections; text-only, no tool calls).
11. Cost tracking (cost-tracker.ts)
CostTracker accumulates UsageEntry[] and computes UsageStats:
interface UsageStats {
totalInputTokens; totalOutputTokens; estimatedCost; turns;
byModel: Record<string, { input; output; cost }>;
}
DEFAULT_MODEL_PRICING(per 1K tokens):claude-sonnet-4-60.003/0.015,claude-haiku-3-50.00025/0.00125,claude-opus-4-60.015/0.075 (+ dated aliases).calculateCostfalls back to Sonnet pricing for unknown models.setBudget(dailyUsd, mode)withmode: 'soft' | 'hard'.checkBudget()returnsfalsein soft mode when over budget, or throwsBudgetExceededErrorin hard mode.getDailyTotal()is the session estimated cost (proxy for daily);getWorkspaceCost(id)filters byworkspaceId.
12. Turn tracing (turn-context.ts)
H-AUDIT-1 contract: a turnId (UUID v4) is generated once at chat-route turn entry (generateTurnId()) and threaded explicitly as turnId?: string through every stage (agent-loop → orchestrator recall → search → prompt-assembler → cognify → tool calls). No globals/AsyncLocalStorage — propagation is tsc-verifiable. logTurnEvent(turnId, payload) is silent when turnId is undefined; tests can startTurnCapture() / stopTurnCapture().
13. Production wiring (packages/server/src/local/routes/chat.ts)
POST /api/chat (SSE) assembles the full prompt stack in this order, then calls runAgentLoop (via server.agentRunner ?? runAgentLoop):
userSystemPrompt(highest priority, if set)assembled?.system ?? orch.buildSystemPrompt()(identity + self-awareness + preloaded context, or the PromptAssembler output)# About the User(profile: name/role/company/industry/writing-style/brand)# Who You Are+## Your Runtime(date/time/platform/shell/workspace/session facts)activeSpec.rules(behavioral spec, with self-evolution overrides)- Loaded skills section
Workspace Nowstructured state block# User Corrections(actionable improvement signals)composePersonaPrompt(prompt, persona, undefined, workspaceTone)— wraps everything with the active persona (override > workspace default) + tone## Response shape(the assembler'sresponseScaffold, if any)
Per-turn memory recall (sessionOrch.recallMemory(query)) is concatenated as recalledContext. Persistent agents set maxTurns: 200. After the loop, commitSurfacedSignals() and autoSaveFromExchange(message, result.content) run.
14. One agent turn, end to end
flowchart TD
A[User message arrives at POST /api/chat] --> B[generateTurnId UUID v4]
B --> C[Orchestrator.recallMemory query<br/>catch-up vs semantic + injection scan + temporary/deprecated filter]
C --> D[buildSystemPrompt / buildAssembledPrompt<br/>identity + self-awareness + preloaded context]
D --> E[Route layers: profile + runtime facts + behavioral-spec.rules<br/>+ skills + Workspace Now + corrections]
E --> F[composePersonaPrompt<br/>persona systemPrompt + WRITE_DISCIPLINE if writable + tone]
F --> G[runAgentLoop config<br/>system + history messages, tools to OpenAI schema]
G --> H{turn < maxTurns?}
H -- no --> Z[Return: preservedAnswer ?? accumulated ?? Max-turns message]
H -- yes --> I[POST litellmUrl/chat/completions<br/>signal = client-abort + 300s timeout]
I -- network reject --> R1[handleNetworkError: backoff, turn--, retry]
I -- !ok 429/5xx --> R2[handleNonOkResponse: Retry-After / backoff, turn--, retry]
R1 --> H
R2 --> H
I -- ok --> J[Parse stream / json: content + tool_calls + usage]
J --> K{tool_calls present?}
K -- no --> L[maybeFireCompletionGate<br/>D3 verification -> D4 phantom-write -> D1 skill-distillation]
L -- a gate fired --> M[push assistant + corrective user directive] --> H
L -- none fired --> Y[Return AgentResponse content, toolsUsed, usage]
K -- yes --> N[push assistant message with tool_calls]
N --> O[for each tool_call: executeToolCall]
O --> P[11-step chain:<br/>parse args - onToolUse - governance block - pre:tool - pre:memory-write<br/>- LoopGuard.check - execute - scanForInjection - onToolResult - post hooks]
P --> Q[push role:tool result message] --> H
Y --> S[commitSurfacedSignals + autoSaveFromExchange]
Z --> S
15. Frontend rebuild — must-know facts
- The frontend never talks to the agent loop directly — it hits the HTTP/SSE surface (
POST /api/chat, agent-run routes). The runtime returns{content, toolsUsed, usage:{inputTokens, outputTokens}}; the route streams tokens viaonTokenand tool events viaonToolUse/onToolResult. - Personas are 22, not 13/17. Build any picker from
listPersonas()output (id/name/icon/tagline/bestFor/wontDo/failurePatterns).isReadOnlypersonas (planner,verifier) must surface "read-only" affordances;coordinatoris feature-gated. - Gates change UX: after a "final" answer the loop may inject ONE more turn (verification re-run, forced file write, or skill authoring). The UI should expect a brief continuation rather than treating the first no-tool answer as terminal.
- Tool results are sanitized before the UI ever sees them (
scanForInjectionstep 8) — a[SECURITY] … sanitized.string means injection was caught, not a backend bug. - Cost/usage comes from
usageon the response plusCostTracker(DEFAULT_MODEL_PRICING, soft/hard budget). Surface token/$ estimates from there; unknown models bill at Sonnet rates.