DEV Community

Cover image for DeepSeek Harness Series (06): System Prompt Assembly — Engineering Dynamic Prompts
WonderLab
WonderLab

Posted on

DeepSeek Harness Series (06): System Prompt Assembly — Engineering Dynamic Prompts

Start with a Concrete Question

When dsh has 20 plugins loaded simultaneously — each wanting to add something to the system prompt — what does the model actually receive?

Which of those 20 segments comes first? When a plugin is unloaded, how does its contribution disappear? Is it possible for a segment to update dynamically on each request?

All of these questions point to the same mechanism: ctx.systemPrompt.


The Problem with Static String Concatenation

The naive approach is straightforward: each plugin exposes its prompt string, and the main loop concatenates them in sequence.

This immediately runs into three problems:

  1. Uncontrollable ordering: plugins are placed by load order, but logically 'role definition' should lead and 'tool list' should trail — these orderings are not the same thing
  2. Tight coupling: the main loop must know every plugin's name and interface, creating implicit dependencies between plugins
  3. No dynamic updates: a string assembled once is fixed forever, but some content (timestamps, working directory) needs to be recomputed on every request

dsh's solution is registration plus ordering: each plugin registers its prompt fragment with a central service, which owns sorting and assembly.


ctx.systemPrompt: Registering a Section

A Section is the basic unit of dsh's prompt system — a named, ordered fragment of prompt text.

// Simplest usage: register a static Section
ctx.systemPrompt.section({
  name: 'my-plugin:instructions',  // Unique name; registering the same name twice throws
  order: 1000,                      // Sort key; all Sections are arranged in ascending order
  text: 'You are a helpful assistant.',  // Static text
})
Enter fullscreen mode Exit fullscreen mode

name follows the convention plugin-name:section-name to avoid collisions between different plugins.

order controls this fragment's position in the final system prompt. dsh ships pre-defined position constants accessible via ctx.systemPrompt.getSectionOrder(), so plugins can slot in at the right logical place rather than fighting over raw numbers.

section() returns a disposer function — calling it removes the Section from the service. dsh's plugin system calls the disposer automatically when a plugin is unloaded, so no 'zombie' prompt fragments linger.


Dynamic text: Recompute on Every Request

Some content needs to be freshly computed on each assembly pass. Pass text as a function:

// Dynamic text: this function is called fresh on every assembly
ctx.systemPrompt.section({
  name: 'my-plugin:context',
  order: 2000,
  text: (context) => {
    // context.scope identifies the current Agent's scope (distinguishes sub-agents)
    // context.signal is the cancellation signal for the current Turn
    return `Current time: ${new Date().toISOString()}`
  },
})
Enter fullscreen mode Exit fullscreen mode

The context object carries the current runtime context — you can read from it to get Agent metadata, the cancellation signal, or session information, then generate different prompt text accordingly.

Use text: string for static content, text: (context) => string for dynamic content; dsh handles both transparently during assembly.


Variable Interpolation: {{variable_name}}

If the same dynamic value appears in multiple Sections, rewriting context.agent?.session?.header?.cwd in each one is tedious. dsh provides a variable registration mechanism:

// Register a variable (can be referenced in any Section's text with {{variable_name}})
ctx.systemPrompt.variable('user_name', (context) => {
  // Read the working directory from the Agent Session header, or fall back to a default
  return context.agent?.session?.header?.cwd ?? 'unknown'
})

// Reference the variable in a Section — no manual string interpolation needed
ctx.systemPrompt.section({
  name: 'my-plugin:greeting',
  order: 500,
  text: 'Hello, {{user_name}}! I am your AI assistant.',
})
Enter fullscreen mode Exit fullscreen mode

During assembly, dsh evaluates all dynamic variables first, then substitutes the results into every Section's text. Variables are also scope-isolated — more on that below.


Scope Shadowing: Per-Sub-Agent Prompt Customization

dsh supports multi-agent scenarios. When an Agent spawns a sub-agent, the sub-agent can register its own Sections that shadow (override) any global Section with the same name.

The rules are simple:

  • Global Sections are visible to all Agents
  • Scope-bound Sections (attached to a specific Agent scope) shadow any global Section with the same name
  • Shadowing is local to that scope — other Agents are unaffected

This lets you inject a completely different role definition for a specialized sub-agent without touching the global configuration.

There is also a special field complete?: true:

ctx.systemPrompt.section({
  name: 'core:agent-instructions',
  order: 0,
  text: 'You are a specialized code review agent. Be concise and focus on bugs.',
  complete: true,  // When true, this Section IS the entire system prompt; all others are ignored
})
Enter fullscreen mode Exit fullscreen mode

complete: true means "I am the entire system prompt — no other plugins should speak." This is intended for scenarios requiring full ownership of the prompt, such as purpose-built tool Agents.


Automatic Tool Schema Injection

Beyond text Sections, the model also needs to know which tools are available on each request. Tool schemas (format descriptions) are injected as part of the prompt.

// ctx.systemPrompt.tools() registers a tool schema provider
// Normally called internally by ctx.tools — plugins don't need to do this manually
// The code below illustrates the internal mechanism for understanding purposes
ctx.systemPrompt.tools((context) => {
  return {
    // Schemas for all registered tools in the current Agent's scope
    schemas: ctx.tools.schemas(context.scope),
    // Tool name list (for compact reference)
    knownNames: ctx.tools.knownNames(context.scope),
  }
})
Enter fullscreen mode Exit fullscreen mode

Tool schemas are dynamic — they're re-evaluated before every Step. So even if a tool is registered or deregistered at runtime, the model request always sees the current tool list.


The system-prompt/assemble Waterfall Hook

Sometimes you need to process the full sorted collection of Sections after they're assembled but before they're rendered into a string — for instance, suppressing a Section based on current state, or injecting an urgent notice.

dsh provides the system-prompt/assemble waterfall hook:

// Waterfall listener: intercept the sections list before final rendering
ctx.on('system-prompt/assemble', async (assembly, context, next) => {
  // assembly.sections  — sorted Section list
  // assembly.contexts  — dynamic context list
  // You can read, modify, or completely replace sections

  if (someCondition) {
    // Example: suppress a specific Section under a certain condition
    assembly.sections = assembly.sections.filter(
      s => s.name !== 'some-plugin:section-to-suppress'
    )
  }

  // Must call next() — skipping it aborts the entire assembly chain
  return next()
})
Enter fullscreen mode Exit fullscreen mode

This is a waterfall pattern: all listeners execute in registration order, each receiving the assembly object, optionally mutating it, and then calling next() to hand control to the next listener. Not calling next() skips all remaining listeners and the default rendering step.


Prompt Caching: Avoiding Redundant Token Processing

dsh supports Anthropic API's Prompt Caching feature (cache_control).

The core idea: when the system prompt contains large, stable sections (tool documentation, knowledge bases, long role definitions), reprocessing those tokens on every request is wasteful. By injecting cache_control: { type: 'ephemeral' } markers at the end of those sections, you tell the API "everything before this point can be cached — don't recompute it if the content is unchanged on the next request."

What's worth caching?

  • Long and stable: content that rarely changes across Turns
  • Early in the prompt: the earlier the cache boundary, the more content it protects
  • Examples: tool lists (long but identical across steps), static background knowledge, role definitions

What to avoid caching: dynamic timestamps, per-step state data — when content changes, the cache entry is invalidated, wasting a precious cache boundary slot.

dsh injects cache_control markers automatically during the renderPrompt phase. Plugin authors generally don't handle this manually.


Full Execution Flow

Here is the complete assembly flow before each Step:

Before every Step:

  ctx.systemPrompt.assemble(context)
    │
    ├── Collect all registered Sections (global + current scope)
    ├── Scope Sections shadow global Sections with the same name
    ├── Sort ascending by order (ties broken by name code unit order)
    ├── Evaluate dynamic text functions
    ├── Interpolate {{variables}}
    │
    ▼
  system-prompt/assemble waterfall
    │  All listeners run in sequence, may modify sections/contexts/tools
    │
    ▼
  renderPrompt()
    │  Concatenate all segment texts
    │  Inject cache_control markers at eligible segment boundaries
    │
    ▼
  surface event written to Session log
    │  First Step  → append a new system/message node
    │  Later Steps, prompt changed → replace the most recent system node
    │  Prompt unchanged → reuse the existing node (no new log entry)
Enter fullscreen mode Exit fullscreen mode

The final 'reuse' step matters: if the system prompt hasn't changed between two Steps, no new system/message event is written to the Session log. This keeps logs lean and reduces overhead in Prompt Caching hit evaluation.


Walkthrough: Registering a Custom Section

Putting all the mechanisms together in one complete plugin example:

// Complete plugin example: inject working directory context
export const name = 'my-context-plugin'

export function apply(ctx: Context): void {

  // 1. Register a dynamic Section that injects the current working directory
  ctx.systemPrompt.section({
    name: 'my-context-plugin:workspace',
    // getSectionOrder reads a pre-defined position constant instead of a hard-coded number
    order: ctx.systemPrompt.getSectionOrder('context:workspace'),
    text: (context) => {
      const cwd = context.agent?.session?.header?.cwd
      // Empty string return → dsh automatically skips this Section
      if (!cwd) return ''
      return `Working directory: ${cwd}\nAll file operations are relative to this directory.`
    },
  })

  // 2. Register a variable so other Sections can reuse it without duplication
  ctx.systemPrompt.variable('workspace_cwd', (context) => {
    return context.agent?.session?.header?.cwd ?? 'not set'
  })

  // 3. Use a scope-bound Section to override a global Section (only within this sub-agent)
  //    Use case: a specialized sub-agent for a particular task that needs full role control
  ctx.systemPrompt.section({
    name: 'core:agent-instructions',  // Same name as the global → shadows it
    order: 0,
    text: 'You are a specialized code review agent. Be concise and focus only on bugs and security issues.',
    complete: true,  // This Section is the entire system prompt
  })
}
Enter fullscreen mode Exit fullscreen mode

A few details worth noting:

  • When a dynamic text function returns an empty string, dsh automatically skips the Section — no blank lines in the final prompt
  • getSectionOrder() is more resilient than a hard-coded number: if dsh ever adjusts a pre-defined position internally, your plugin follows automatically
  • complete: true can coexist with ordinary Sections in the same plugin; it only takes effect within the scope it's bound to

Design Summary

Design Decision Problem It Solves
Registration + disposer Plugins clean up automatically on unload — no residual fragments
order sorting Content logical order is decoupled from plugin load order
Dynamic text function Per-request recomputation supports timestamps, state, and more
Variable interpolation Shared dynamic values computed once, used everywhere
Scope shadowing Sub-agents can customize prompts without affecting the global config
complete: true Purpose-built Agents can take full ownership of the system prompt
Waterfall hook A unified interception point after assembly, before rendering
Prompt Caching Long stable sections are automatically marked for cache, reducing token costs

What's Next in the Series

The next article on Capability Seams covers how dsh lets you replace the entire filesystem, Shell, and sandbox implementation with a single line of configuration — the same tool interface works whether the backend is a local shell, a Docker container, or a cloud sandbox, and plugins never need to know the difference.


Check out PrimeSkills — a curated marketplace of AI agents and skills validated in real-world, enterprise-grade workflows. Not demos — things that actually work in production.

Find more on my Homepage

Top comments (0)