The system prompt is not a single authored string. It is assembled per model step by the SystemPrompt service (packages/core/system-prompt/src/index.ts, exposed as ctx.systemPrompt), combining registry-contributed sections, dynamic contexts, tool schemas, and prompt variables into a PromptAssembly, then rendering it to text. Assembly happens once at agent/pre-step — before the first request of a turn, not on every retry inside a step.
| Package | Owns |
|---|---|
@deepseek-ai/dsh-system-prompt | SystemPrompt service, PromptSection, PromptContext, variables, tool ordering, renderPrompt |
@deepseek-ai/dsh-llm | ToolSchema, ContextSnapshotSection types the prompt consumes |
@deepseek-ai/dsh-agent-instructions | A runtime-context contributor that injects workspace instructions |
The inputs: sections, contexts, tools, variables
The registry accepts four kinds of contribution, each registered in the calling context's scope (global or scoped via agent.ctx). The two static text inputs:
interface PromptSection {
readonly name: string
readonly order: number // sections concatenate ascending; -100 identity, 0 persona, 100–199 tool guidance
readonly text: string | ((context: AssembleContext) => string)
readonly complete?: boolean // become the sole prompt section
}
interface PromptContext {
readonly name: string
readonly order: number // contexts join ascending
readonly text: string | ((context: AssembleContext) => string)
}A section (systemPrompt.section()) contributes fixed prompt prose. A context (systemPrompt.context()) contributes dynamic, model-visible state materialized as a durable user-role snapshot (documented under sessions) — both order differently and both support interpolation. Tools come from systemPrompt.tools(provider), and variables from systemPrompt.variable(name, provider).
The fixed sections
The SystemPrompt constructor installs the always-on sections from its config:
constructor(ctx, config) {
if (config.includeHarnessIdentity ?? true) {
this.section({ name: 'harness:identity', order: -100, text: 'You are an AI agent powered by DeepSeek Harness.' })
}
this.section({ name: PERSONA_SECTION, order: PERSONA_ORDER, text: config.persona ?? '' })
...
}The two constants are exported because a composition deliberately targets them:
export const PERSONA_SECTION = 'deployment:persona' // the persona slot, order 0
export const PERSONA_ORDER = 0deployment:persona is the scope-visible persona mechanism: a scoped section (e.g. an agent preset's persona) holds the same name, so shadowing replaces the deployment's global persona for that scope rather than duplicating it.
The assembly flow
assemble(context) gathers global and scope-chain contributions, detaches tool parameters, applies canonical ordering, and runs real hooks:
async assemble(context: AssembleContext = {}): Promise<PromptAssembly> {
const scope = context.scope
const scopeLayers = this.layers.chainLayers(scope)
const runtimeContextSuppressed = !this.layers.global.runtimeContextSuppressors.isEmpty()
|| scopeLayers.some(layer => !layer.runtimeContextSuppressors.isEmpty())
// scoped variables shadow globals...
// scoped sections shadow globals before the stable order sort...
const sectionByName = this.layers.merge(scope, layer => layer.sections)
const contextByName = this.layers.merge(scope, layer => layer.contexts)
...
const assembly: PromptAssembly = { sections, contexts, tools, variables }
const transformed = await this.ctx.waterfall(
scopeTarget(this, scope), 'system-prompt/assemble', assembly, context,
() => Promise.resolve(assembly),
)
if (completeSection === undefined && !runtimeContextSuppressed) return transformed
return { ...transformed, sections: ..., contexts: ... }
}The returned PromptAssembly:
interface PromptAssembly {
sections: AssembledSection[]
contexts: AssembledContext[]
tools: ToolSchema[]
variables: Record<string, string | undefined> // unevaluated until render
}Section text and context text remain uninterpolated in the assembly; only renderPrompt/renderContextSections resolves s. The assembly runs through the system-prompt/assemble expert waterfall (scope-filtered), so an extension plugin can reshape it authoritatively; a section marked complete is then restored as the sole prompt section (and more than one active complete section fails the assembly).
Tool schema assembly and ordering
Tool schemas are collected from global and scope-chain toolProviders, each detaching parameters via structuredClone. They are canonically ordered — lexicographically unless a toolOrder config lists them, in which case orderTools interleaves unlisted tools at the reserved <unlisted-tools> marker:
export const TOOL_ORDER_REST = '<unlisted-tools>'Each provider's ToolProviderResult may also report a knownNames universe — the pre-restriction name set used to distinguish a configured-name typo from a known tool deliberately hidden in that scope. Tool restriction filtering happens in the tools registry, not here (see tool-presentation.md).
Rendering
renderPrompt interpolates strict references, drops empty sections, and joins the rest with blank lines:
export function renderPrompt(assembly: PromptAssembly): string {
return assembly.sections
.map(section => interpolate(section, assembly.variables, 'section'))
.filter(text => text.length > 0)
.join('\n\n')
}Variable names must match [a-z][a-z0-9_]*; a malformed, unknown, or empty-value reference throws. joinContextSections wraps the runtime-context snapshot with the fixed opener "Current runtime context. This snapshot supersedes earlier runtime-context snapshots." — the same opener this harness's own runtime exposes.
When assembly happens
In the loop's preStep (see agent-loop.md), assembly runs once per proposed step with assembleContextFor(agent, signal) — which sets scope: agent and the current turn's signal — then the sections are rendered and surfaced through the agent/pre-step waterfall. The tool schemas and rendered system text become part of the logged request/header, so the exact prompt of a past step is reconstructable from the session log.
Model-selection variables
A model selection installed via installModelSelection hooks system-prompt/assemble to inject provider and model prompt variables, and agent/request to force the provider/model pair on request config. Details on model-selection.md.
Further reading
- The agent loop — where
systemPrompt.assembleis called per proposed step. - Session management — how the rendered prompt is logged in
request/header. - Scope system — how scoped sections/variables shadow globals, including the persona.
- Model selection — the
provider/modelprompt variables. - Repo source:
packages/core/system-prompt/src/index.ts,packages/context/agent-instructions/src/index.ts. - Official scaffold:
docs/subsystems/system-prompt.md,docs/config-catalog.md(the system-promptpersona/toolOrderkeys).