The Context System
In one sentence: what the model sees = the object returned by
agent-loopassembling system prompt + tool schemas + derived session history into a controlled context just before a request; when overlong, it goes through compaction rather than truncation. Thecontext/packages only provide additional context-injection sources; they are not the assembly layer.
This is the second core lesson after Agent Main Loop. It is what lets you understand "what exactly is sent in each model request, and why it is sometimes slow / spends tokens".
One — Who assembles the context (correcting a common misconception)
Many believe "the context is assembled by the context package": not true. The real division of labor:
| Layer | Responsibility | Owner |
|---|---|---|
| Assembly subject | Each step assembles system prompt + tool schemas + derived messages into a request | agent-loop (assembleContextFor / buildRequest + agent/request assembly waterfall) |
| System-prompt registration | Plugins contribute ordered prompt sections, tool schemas, and named variables, and render them | core/system-prompt (ctx.systemPrompt) |
| Tool-list presentation | Decides whether to present tools to the model as native/code/both | core/tools + core/agent-tool-presentation |
| Session history | Event-sourced log → surface-message projection | core/session |
| Extra injection sources | Inject additional model-visible context into the request (do not define tools) | context/* |
| Compaction | Collapses history when over a limit | compaction/* |
Remember in one sentence: assembly is agent-loop's job; context/* is merely "optional extra words fed to the model", and system-prompt is the registration registry for prompt sections.
Two — System prompt: the ctx.systemPrompt registry
@deepseek-ai/dsh-system-prompt is the system-prompt assembly registry. Plugins contribute ordered sections, tool schemas, and named variables; agent-loop assembles once per step and renders them into the full model prompt.
The 6 core entry points
| Entry | Purpose |
|---|---|
ctx.systemPrompt.section({ name, order, text, complete? }) | Contributes a prompt section, sorted ascending by order |
ctx.systemPrompt.context(provider) | Contributes ordered dynamic context, evaluated per eligible assembly into a sourced user-role snapshot |
ctx.systemPrompt.suppressRuntimeContext() | Suppresses all dynamic context for the current scope; disposal restores it |
ctx.systemPrompt.tools(provider) | Contributes tool schemas (evaluated with the current context on each assembly) |
ctx.systemPrompt.variable(name, provider) | Contributes a prompt variable, referenced in section text with {{name}} |
ctx.systemPrompt.assemble(context?) | Runs a full assembly (global layer + scope layer, through a waterfall) |
order: how sections are placed
Sections are concatenated in ascending order. The order conventions in the source:
-100 harness identity (fixed "You are an AI agent powered by DeepSeek Harness.")
0 deployment persona (only one, from config)
100–199 tool guidance (tool:bash / tool:read …)
includeHarnessIdentity: true(default) injects the fixedYou are an AI agent powered by DeepSeek Harness.includeRuntimeContext: true(default) evaluates dynamic context providers; false drops provider and waterfall-added contexts while sandbox, approval, delegation, and other owning services remain activedeployment:personais the ONE section in config (the global deployment persona); an agent-scoped contribution can shadow it- A section with
complete: truebecomes "the only prompt section" after the assembly waterfall; more than one effectivecompleteat the same layer → assembly fails
Scope layering
Like tools, systemPrompt is layered by the scope of the calling context:
- Sections/variables/tools registered inside
agent.ctx→ effective only for that agent, and shadow same-named global items - Global registration → visible to everyone
This is what makes "give a specific agent its own persona/toolset" possible (see Agent Presets & Persona).
Variable rendering: strict, not stretchy
renderPrompt(assembly) performs strict {{variable}} interpolation, then drops empty sections and joins with blank lines. Fail-loud:
- References to unregistered variables, references registered but without a value, and broken
{{…}}groups → throw (better to fail than to send a malformed prompt) - An orphan
{{(with no following}}) passes through as-is - Replaced values are not rescanned
Strictness is for stability: byte-for-byte identical system prompts are the prerequisite for KV-cache reuse (see below).
Assembly goes through the system-prompt/assemble waterfall: listeners can cooperatively modify or replace the assembly (filtered by scope), after which the complete-section constraint and runtime-context suppressor are applied. Dynamic contexts are separate from system-prompt sections and become sourced user-role snapshots only when present.
Want to give an agent a dynamic fact like "today's date"? Register a
variableprovider and reference{{date}}from any section: much cleaner than hardcoding it into a persona. The agent loop already registers the three variablesprovider,model,cwd.
Three — Tool schemas into the assembly
Tool schemas are part of the assembly: the README's own words are "what the model is told it can do is one coherent thing": even though the adapter transmits the schema online as a standalone wire field, at the assembly level it is one whole with the prompt.
ToolRuntimeautomatically registers itself as a tool provider, handing the visible tool schemas intosystemPrompt- The
toolOrderconfig can explicitly order how tools are presented (listnames, use'<unlisted-tools>'to placeholder the rest); misconfiguration fails loud - The presentation mode (native / code / both) is decided by
ctx.tools.presentAs+core/agent-tool-presentation; see Tool Execution
Four — Request header: the "business card" of a request
Every model request carries a request header recording that request's route and assembly:
{ config: { provider, model, reasoningEffort, maxTokens },
system: <the assembled system prompt>,
tools: <the tool list presented to the model> }
request/header has several semantics worth understanding:
reason:initial/resume/change. When the header is byte-for-byte identical to the previous one, it does not re-emit thechangeevent: this is the switch that does not invalidate the prefix cache- The compaction summary call reuses the session's current request header (aligning the KV cache); but "auxiliary calls (subagents, etc.) reuse the same header" has no source basis: subagents have their own header
- Paired
request/contextevent, carrying provider/model/contextWindow routing metadata request/headeris recorded in the session log: you can always look in the JSONL to see which model a given message actually used, and how the system prompt was assembled
This is where the "adapter-default marking" from Agent Main Loop lands: the request header records which fields come from the adapter, and the next waterfall removes them so the current route re-materializes defaults, keeping HMR from cross-contaminating.
Five — Context injection sources: context/*
The context/ group's product plugins add extra model-visible context to requests, but do not define tools:
| Package | ctx key | Role |
|---|---|---|
session-reference | ctx.sessionReferenceResolver | Bounded snapshots of other sessions |
time-context | Current time / elapsed time | |
tmux-context | tmux location context | |
agent-instructions | Workspace-instruction context |
agent-instructions ships in the default demo bundle; the rest are opt-in. They are "optional feed for the model" and do not participate in the system prompt's order-based assembly.
Six — Token metering (token-meter): the basis for the compaction decision
How does compact know "when to compact, and how much"? Through the ctx.tokenMeter service mounted by @deepseek-ai/dsh-token-meter (mounted by default in the base bundle). It is a replay-aware metering singleton: each measurement is based on the latest consumed-log revision of the persisted log, so compaction and other pressure-sensitive plugins share the same ledger without depending on CompactionEngine.
Estimation is a "fixed heuristic", not an exact tokenizer
The meter has no configuration items, deliberately using only a single fixed heuristic: ≈1 token per 4 characters, plus structural overhead for role / block / request-envelope fields. Any config key is rejected — model capacity belongs to the adapter that owns the precise provider/model route, obtained via ctx.llm.resolveModelInfo().context.
Two entry points
| Operation | Returns | Notes |
|---|---|---|
ctx.tokenMeter.measure(session, requestHeader?) | { totalTokens, surfaceTokens, nodes[], … } | Synchronizes once at one consumed revision, returns a deeply immutable detached snapshot |
ctx.tokenMeter.estimateMessage(message) | Estimate for a single message | Prices one message with the same fixed heuristic |
totalTokens= the pressure of request + response;surfaceTokens= the heuristic total counting only the surface, exactly equal to the sum ofnodes[].tokens.- A
requestHeaderoverride affects only the pressure fields; the surface fields still describe the current session. - Each call clones the located nodes, so measurement complexity is O(surface).
The reuse rule for provider usage
Provider-reported real usage can be reused only when the canonical request envelope (provider / model / tools / prefix / call config) of the latest successful call is byte-for-byte identical to the current measurement's envelope, and its total is not below the full heuristic anchor of that call; later successful calls supersede earlier anchors. Otherwise it falls back to a full heuristic estimate over "the whole envelope + surface". Surface changes (including the shrink-replacements after compaction) are accumulated with sign relative to the anchor, so negative deltas also count correctly.
Session projections: three quantities for the UI
When the composition layer provides ctx.sessionProjections, the meter registers three units through an optional child fiber:
| Projection | Contents |
|---|---|
tokenUsage | uncachedInputTokens / outputTokens / cacheReadTokens / cacheWriteTokens of the full persisted log (the four buckets — input/cache-read/cache-write/output — are disjoint; reasoning is no longer double-counted) |
contextPressure | optional pressureTokens (latest provider-reported prompt size), optional projectedTokens, optional contextWindow (route capacity from the latest request/context) |
contextBreakdown | heuristic systemTokens / toolsTokens / messageTokens — the composition of the context, not the provider-billed size |
The key is projectedTokens: how many tokens the next request's prompt will cost = the provider-sampled baseline + heuristic re-pricing of surface deltas since the sample (clamped at 0). Only deltas are estimated, so it is both anchored to the provider and reactive the moment content lands (or is compacted into shadow). That is exactly why it exists: the compaction summary calls ctx.llm.stream() directly and does not itself report any usage, so pressureTokens still reports the pre-compaction prompt right after compaction, until the next full turn completes — the occupancy display reads projectedTokens.
The approximation is deliberate. The occupancy fields are independent last-wins records, not an atomic observation of a single request; switching models pairs the new capacity with the previous route's stale sample. The occupancy percentage is a human-facing reference, not a billing record nor a gate input — nothing in the harness uses it for decisions; compaction reads
measure(). CJK text and JSON schemas are severely underestimated under "4 chars / token", so the three parts ofcontextBreakdownnever add up toprojectedTokens.
Seven — Context compaction (compact): don't truncate, fold
Long sessions aren't handled by "chopping off the beginning", but by folding:
folding = "shadowing" a section of history
→ replace the model-visible surface with a summary/report
→ the full log still stays in the persistence layer (event sourcing: the log is the only source of truth)
→ what the model sees is the surface after a surfaceOp: replace
Mechanism highlights:
CompactionEnginethree entry points:compactIfNeeded/compactNow/compactRegion- Event sequence:
compaction/start→compaction/summary→compaction/end; with acompaction/end-seedorphan lock and recovery (a crash never leaves "half-compacted" state) - Surface constraint:
surfaceOp(append/replace) is only legal onuser/message,assistant/message,tool/result; the compaction events themselves are log-only and never enter the surface - Compaction pressure is attached to
agent/pre-step; the canonical overflow repair is attached toagent/request-error(see Event System and Agent Main Loop)
Result pruning (compaction-tool-result-pruner): cut the feed before summarizing
@deepseek-ai/dsh-compaction-tool-result-pruner provides ctx.toolResultPruner, an optional companion to compaction-basic (mounted by default in the base bundle; explicitly disabled in the web-mode bundle). It is not a compaction backend nor a model-facing tool, but a model-free, replayable prune: it rewrites over-budget tool/result surface nodes into "bounded head + fixed omission marker + bounded tail", while the full raw events remain in the append-only session log.
- Trigger timing: after compaction-basic's pressure or canonical overflow is triggered but before it selects a region, it reads it (
ctx.get('toolResultPrune')); steps below pressure never prune. - Rewrite shape: each over-budget result is replaced by a new appended
tool/resultcarrying{ surfaceOp: { op:'replace', start: originalSeq, end: originalSeq }, sourceEventSeqs:[originalSeq] }; the replacement only changescontent, preservingturn/step/callId/error fields/meta. - Pruned remeasure: compaction-basic re-measures through
ctx.tokenMeter; if pressure drops below the safety line it skips the summary; otherwise it summarizes the pruned surface.
| Config | Default | Meaning |
|---|---|---|
thresholdChars | 8192 | Prune only when text totals exceed this many Unicode code points |
headChars | 4096 | Head code points retained |
tailChars | 1024 | Tail code points retained |
measureContent(blocks)counts the Unicode code points oftextblocks;pruneContent(blocks)returns a bounded replacement, ornullif the content is already within threshold. Non-text blocks keep their relative positions; slicing never splits a UTF-16 surrogate pair (but may split a multi-code-point grapheme cluster).- Each output is exactly "head + marker + tail" and strictly smaller than the triggering input, so a second pass never re-produces a replacement.
Third-party
tool-rewind's SHRINK "the summary must be shorter than the folded region" belongs to a repository-external plugin whose semantics cannot be verified from the current source: treat it as an ecosystem reference, not a DSH built-in.
Eight — Three perspectives: model / token / KV cache
| One assembly | What the model sees | Token effect | KV-cache effect |
|---|---|---|---|
| System prompt | identity + persona + each plugin section (after strict interpolation) | identity costs tokens fixed; persona/section text is re-paid per request | repeated only when identity/persona/sections/order are byte-for-byte unchanged |
| Tool schema | visible tools (via toolOrder/restriction) | schema is re-paid per step | schema invalidation starts at the first changed token |
| Session history | surface messages (without raw chunks/boundaries) | grows with surface messages; cumulative re-send per step across multi-tool turns | plain growth is append-only; surface replacement/compaction invalidates the prefix |
Practical takeaway: to save tokens and preserve the cache, keep the system prompt and tool schemas stable; any "dynamically assembled persona" invalidates the prefix cache from the change point onward, costing a full recompute.
Nine — Source evidence (event layer)
{ "type": "user/message", "seq": 9, "data": {...} }
{ "type": "request/header", "seq": 11, "data": { "header": { "config": {...}, "system": "{{system}}", "tools": "{{tools}}" } } }
{ "type": "request/context","seq": 12, "data": { "provider": "deepseek-official", "model": "deepseek-v4-flash", "contextWindow": ... } }
Ten — Verification
# Inspect one session's request header (zstd-compressed by default, two-level directory)
zstdcat ~/.dsh/sessions/*/*/session.jsonl.zstd | grep "request/header" | head -1
# Inspect the assembled system prompt (including identity + persona + each section)
zstdcat ~/.dsh/sessions/*/*/session.jsonl.zstd | grep -o '"system":.*' | head -1
# Check whether variable rendering took effect (strict interpolation, malformed fails)
zstdcat ~/.dsh/sessions/*/*/session.jsonl.zstd | grep -i "model\|cwd" | head
Next steps
- Agent Main Loop: how the context is consumed by the loop (assembly is part of it)
- Tool Execution: the execution pipeline behind tool schemas
- Agent Presets & Persona: take full control of an agent's prompt with a
complete:truepersona - Write a Service: inject dynamic facts into an agent with
variable()