System prompt lineage, string component origins, LLM API calling, and parallelism handling
Table of Contents
The system prompt is assembled in src/agents/system-prompt.ts via buildAgentSystemPrompt() — a 1,048-line function that composes 20+ string sections from a dozen source files. Below is the complete component tree.
buildAgentSystemPrompt() — Section Assembly Order (Lines 702–1003)
lines = [
// 1. Identity
"You are a personal assistant running inside OpenClaw.",
// 2. Tooling (lines 705–729)
"## Tooling",
toolLines.join("
"), // ← from buildToolsSection(): toolNames + descriptions
// 3. Tool Call Style (lines 747–765)
"## Tool Call Style",
...buildOverridablePromptSection({ override: providerSectionOverrides.tool_call_style, fallback: [...] }),
// 4. Execution Bias (lines 767–771)
"## Execution Bias",
...buildOverridablePromptSection({ override: providerSectionOverrides.execution_bias, fallback: [...] }),
// 5. Safety (lines 672–678)
"## Safety",
...safetySection, // hardcoded safety principles
// 6. OpenClaw CLI Quick Reference (lines 777–788)
"## OpenClaw CLI Quick Reference",
...,
// 7. Skills Section (lines 679–682, 790)
...buildSkillsSection({ skillsPrompt, readToolName }),
// 8. Memory Section (lines 683–688, 791)
...buildMemorySection({ isMinimal, includeMemorySection, availableTools, citationsMode }),
// 9. OpenClaw Self-Update (lines 793–802)
"## OpenClaw Self-Update",
hasGateway && !isMinimal ? [...self-update guidance] : "",
// 10. Model Aliases (lines 806–815)
"## Model Aliases",
...params.modelAliasLines,
// 11. Workspace (lines 819–822)
"## Workspace",
`Your working directory is: ${displayWorkspaceDir}`,
workspaceGuidance,
...workspaceNotes,
// 12. Docs Section (lines 689–694, 824)
...buildDocsSection({ docsPath, sourcePath, isMinimal }),
// 13. Sandbox Section (lines 825–886)
"## Sandbox",
sandboxInfo.enabled ? [...sandbox config details] : "",
// 14. User Identity (line 888)
...buildUserIdentitySection(ownerLine, isMinimal),
// 15. Time Section (lines 889–891)
...buildTimeSection({ userTimezone }),
// 16. Project Context (lines 892–983)
...buildProjectContextSection({ files: stableContextFiles, dynamic: false }),
SYSTEM_PROMPT_CACHE_BOUNDARY, // ← cache invalidation seam
...buildProjectContextSection({ files: dynamicContextFiles, dynamic: true }),
// 17. Extra System Prompt / Group Chat (lines 985–993)
extraSystemPrompt ? "## Subagent Context" / "## Group Chat Context" : "",
extraSystemPrompt,
providerDynamicSuffix,
// 18. Heartbeat (line 995)
...buildHeartbeatSection({ isMinimal, heartbeatPrompt }),
// 19. Runtime (lines 997–1001)
"## Runtime",
buildRuntimeLine(runtimeInfo, ...),
`Reasoning: ${reasoningLevel}`,
// 20. Silent Replies (lines 955–970)
"## Silent Replies",
SILENT_REPLY_TOKEN usage,
// 21. Reactions (lines 912–934, conditional)
// 22. Reasoning Format (lines 935–937, conditional)
]
The system uses buildOverridablePromptSection() pattern throughout — each major section can be overridden by a plugin provider's ProviderSystemPromptContribution:
| Section | Override Key | Fallback Source | File |
|---|---|---|---|
| Interaction Style | providerSectionOverrides.interaction_style |
Empty array (no default) | system-prompt.ts:743 |
| Tool Call Style | providerSectionOverrides.tool_call_style |
Lines 748–764: narration guidance, exec approval | system-prompt.ts:747 |
| Execution Bias | providerSectionOverrides.execution_bias |
buildExecutionBiasSection() |
system-prompt.ts:767 |
| Stable Prefix | providerStablePrefix |
Empty array | system-prompt.ts:772 |
| Dynamic Suffix | providerDynamicSuffix |
Empty string | system-prompt.ts:991–993 |
| String / Section | Defined In | Chars | Type |
|---|---|---|---|
"You are a personal assistant running inside OpenClaw." |
system-prompt.ts:703 | ~52 | Hardcoded identity |
## Safety — 4-line safety constitution |
system-prompt.ts:672-678 | ~300 | Hardcoded safety |
## Tool Call Style — narration/exec approval guidance |
system-prompt.ts:747-765 | ~400 | Policy guidance |
## Execution Bias — auto-complete bias |
system-prompt.ts:768-770 | ~200 | Provider policy |
## OpenClaw CLI Quick Reference |
system-prompt.ts:777-788 | ~400 | CLI commands |
## OpenClaw Self-Update |
system-prompt.ts:793-802 | ~300 | Config/update guidance |
SILENT_REPLY_TOKEN — {{INLINE_REPLY_MARKER}} |
auto-reply/tokens.ts | ~19 | Magic token |
SYSTEM_PROMPT_CACHE_BOUNDARY |
system-prompt.ts (constant) | ~60 | Cache seam marker |
## Model Aliases — alias table from config |
system-prompt.ts:806-815 | Variable | Dynamic from config |
## Workspace + path guidance |
system-prompt.ts:819-822 | Variable | Dynamic from workspaceDir |
## Sandbox — sandbox config details |
system-prompt.ts:825-886 | Variable | Dynamic from sandboxInfo |
## Runtime — runtime info line |
system-prompt.ts:997-1001, 1006-1048 | Variable | Dynamic: agentId, os, model, channel |
## Reactions — emoji guidance |
system-prompt.ts:912-934 | Variable | Conditional on reactionGuidance |
## Reasoning Format |
system-prompt.ts:935-937 | Variable | Conditional on reasoningHint |
## Silent Replies |
system-prompt.ts:955-970 | ~200 | Conditional on promptMode |
| Memory section content | context.ts:buildMemorySection() | Variable | Function output |
| Skills section content | system-prompt.ts:buildSkillsSection() | Variable | Function output |
| Docs / Project Context content | system-prompt.ts:buildDocsSection() | Variable | Function output |
Bootstrap prompt files (.pi/prompts/cl.md) |
bootstrap-prompt.ts | 2,551 + 964 | File content |
The core agent loop lives in run/attempt.ts (3,290 lines). Each user message triggers one run attempt which may involve multiple turns (tool call cycles) within a single LLM API call.
The turn loop is the heart of the agent. Each iteration is one LLM API call that may produce multiple tool calls. The loop continues until the model produces a non-tool-use stop reason.
Turn_Loop:
while (true):
# Build context for this turn
messages = activeSession.agent.state.messages # includes all prior turns
# Call LLM — single API request
response = streamFn(model, messages, options)
# Parse response for stop_reason
stop_reason = response.stop_reason
if stop_reason == "tool_use":
# Model emitted one or more tool_call blocks
tool_calls = response.content.blocks.filter(type="tool_use")
# Execute tool calls
for each tool_call in tool_calls (sequentially):
tool_result = execute_tool(tool_call.name, tool_call.input)
# Append tool_use + tool_result to transcript
messages.append(tool_use_message(tool_call))
messages.append(tool_result_message(tool_result))
# Continue loop — next LLM call gets tool results
elif stop_reason == "end_turn":
# Model produced final answer text
final_text = extract_visible_text(response)
break
elif stop_reason == "msg_edit":
# Model edited a prior message
apply_message_edit(response)
break
else:
# Other stop reasons: max_tokens, stop_sequence, etc.
break
return final_text + payloads
The actual implementation in pi-coding-agent calls streamFn once per turn, which internally handles all tool_calls in the response stream. The transcript is mutated after each tool execution, and the next streamFn call picks up from the updated message list.
streamFn (the LLM streaming function) goes through a multi-stage resolution and wrapping pipeline:
Stage 1: Resolution (stream-resolution.ts)
resolveEmbeddedAgentStreamFn(params):
# Priority 1: Provider-owned stream function
if params.providerStreamFn:
return providerStreamFn # wrapped with auth injection
# Priority 2: OpenAI WebSocket transport
if shouldUseWebSocketTransport:
if wsApiKey:
return createOpenAIWebSocketStreamFn(...)
return currentStreamFn # fallback
# Priority 3: Anthropic Vertex
if model.provider == "anthropic-vertex":
return createAnthropicVertexStreamFn(...)
# Priority 4: Boundary-aware streaming
if currentStreamFn == undefined || streamSimple:
boundaryAware = createBoundaryAwareStreamFnForModel(model)
if boundaryAware: return boundaryAware
# Default: use session's streamFn
return currentStreamFn
Stage 2: Wrapper Chain (attempt.ts lines 1777–1866)
1. innerStreamFn = activeSession.agent.streamFn
2. yield-abort wrapper
→ catches sessions_yield abort signal
→ returns createYieldAbortedResponse()
3. sanitize-malformed-tool-calls wrapper
→ fixes whitespace around tool names
4. trim-tool-call-names wrapper
→ normalizes tool names against allowlist
5. repair-malformed-tool-call-arguments wrapper
→ provider-specific argument repair (if needed)
6. decode-xai-tool-call-arguments wrapper
→ HTML entity decoding for xAI
7. anthropic-payload-logger wrapper
→ logs full request/response payloads
8. handle-sensitive-stop-reason wrapper
→ recovers from "sensitive" stop reason
9. idle-timeout wrapper (streamWithIdleTimeout)
→ aborts after idleTimeoutMs of no response
10. diagnostic-model-call-events wrapper
→ emits TrustedDiagnosticEvents for model calls
These wrappers are applied inside the turn loop — wrapping streamFn so every outbound LLM API request passes through sanitization:
| Wrapper | Trigger Condition | What It Does |
|---|---|---|
dropReasoningFromHistory |
transcriptPolicy.dropReasoningFromHistory |
Removes reasoning blocks from history messages before sending |
dropThinkingBlocks |
transcriptPolicy.dropThinkingBlocks |
Strips thinking blocks from replayed messages (Anthropic restriction) |
sanitizeReplayToolCallIds |
transcriptPolicy.sanitizeToolCallIds |
Normalizes tool call IDs to provider-acceptable formats (e.g., Mistral requires [a-zA-Z0-9]{9}) |
downgradeOpenAIReasoningBlocks |
model.api === "openai-responses" |
Strips reasoning blocks that OpenAI Responses API doesn't support in continuations |
downgradeOpenAIFunctionCallReasoningPairs |
model.api === "openai-responses" |
Separates function-call + reasoning block pairs for OpenAI Responses API |
Model resolution follows a priority chain in model.ts:
resolveEmbeddedAgentModel(params):
1. EXPLICIT: params.model — passed directly (highest priority)
2. CONFIG: params.config → model selection config
3. RUNTIME: environment variable OPENCLAW_MODEL
4. DEFAULT: provider-specific default (e.g., claude-3-5-sonnet)
Then resolve API format:
- "openai" / "openai-compatible" → OpenAI format
- "anthropic" → Anthropic format
- "openai-responses" → OpenAI Responses API
- "anthropic-vertex" → Anthropic Vertex AI
- "azure-openai" → Azure OpenAI
- "openai-codex-responses" → Codex Responses API
The PiModelRegistry in model.ts handles provider plugin registration, inline provider config, and auth storage discovery (reading from ~/.config/openclaw/auth.json or environment variables).
The key design decision: tool calls within a single LLM turn are executed sequentially. The model emits multiple tool_use blocks in one response, but they are processed one at a time in order.
Why Sequential?
Where Parallelism DOES Exist:
runContextEngineMaintenance() runs in background after each turnllm_input hooks fire asynchronously (non-blocking, .catch() fire-and-forget)createOpenAIWebSocketStreamFn() uses WebSocket for real-time token deliveryemitTrustedDiagnosticEvent() fires asynchronouslySession compaction is the primary source of genuine parallelism in the agent loop. It runs in parallel with the main loop via activeSession.isCompacting:
Compaction Flow:
1. After each turn, check if compaction is needed:
- context-window-guard.js evaluates: tokens_used / max_tokens > threshold
2. If needed, trigger async compaction:
activeSession.startCompaction()
3. Compaction is a Pi Agent operation:
- Reads full session transcript
- Summarizes older tool results and intermediate messages
- Produces a condensed transcript that fits in context window
4. The main loop continues accepting new prompts DURING compaction:
- If user sends new message while compacting:
→ abortCompaction() is called
→ new run attempt starts immediately
→ compaction is retried after new turn completes
5. Compaction safety timeout:
- compaction-timeout.ts: max compaction duration
- If exceeded: abortCompaction() + log warning
6. Preemptive compaction:
- Before each prompt, check if context is near full
- If route === "truncate_tool_results_only":
→ truncateOversizedToolResultsInSessionManager()
→ recover without full compaction
Harness selection is a single synchronous decision at run start. It determines which agent backend runs the session:
selectAgentHarness(params):
# Step 1: Check for pinned harness (session-specific override)
pinnedPolicy = resolvePinnedAgentHarnessPolicy(agentHarnessId)
if pinnedPolicy: return resolveToHarness(pinnedPolicy)
# Step 2: Resolve runtime policy from config/env
policy = resolveAgentHarnessPolicy(params)
# Policy.runtime: "pi" | "auto" | "plugin-id"
# Step 3a: If runtime == "pi" → use built-in PI harness
if runtime == "pi":
return piHarness # @mariozechner/pi-coding-agent
# Step 3b: If runtime is a plugin ID → use that plugin harness
if runtime is registered plugin harness:
return pluginHarness
else:
if policy.fallback == "none":
throw Error("harness not registered + no fallback")
log.warn("falling back to PI")
return piHarness
# Step 3c: If runtime == "auto" → auto-select based on provider support
candidates = pluginHarnesses.map(h => ({
harness,
support: h.supports({ provider, modelId, runtime })
}))
supported = candidates.filter(supported).sorted(byPriority()
if supported[0]:
return supported[0].harness # highest priority
else:
if fallback == "none": throw
return piHarness # PI is always available as fallback
# FAILOVER: pi-embedded-runner/compact.ts
maybeCompactAgentHarnessSession(params):
harness = selectAgentHarness(params)
if harness.compact:
return harness.compact(params) # calls PI or plugin compaction
if harness.id != "pi":
return { ok: false, reason: "no compact support" }
return undefined # PI handles compaction internally
SYSTEM_PROMPT_CACHE_BOUNDARY splits stable content (above) from volatile group/dynamic additions (below). Anthropic-family transports reuse the stable portion across API calls.
.pi/prompts/cl.md) are injected conditionally via resolveBootstrapContextForRun() — first-turn vs subsequent-turn behavior.
streamFn call. Tool calls within that response are extracted and executed, results appended, then another streamFn call is made. The loop is at the transcript level, not the HTTP level.
sessions_yield abort signal is intercepted inside the stream wrapper — if the session yields while the LLM is streaming, a synthetic yield_aborted response is returned instead of an error.
TrustedDiagnosticEvent with model, provider, transport, runId, sessionId — traceable across the system.
beginPromptCacheObservation() tracks cache state changes per turn — enabling measurement of cache hit/miss rates across the session.
createTrajectoryRuntimeRecorder() captures tool definitions, context assembly, and prompt sizes for ML training data pipelines.
Generated by Hermes · ClawdyHuang Research · Sovereign AI Analysis Framework
Source: github.com/openclaw/openclaw · Analysis Date: April 2026
Key files analyzed: system-prompt.ts (1,048L) · context.ts (21KB) · compact.ts (1,287L) · model.ts (1,121L) · run/attempt.ts (3,290L) · stream-resolution.ts (132L)