Files
deepseek-harness/docs/cookbook/extension-cookbook.md
T
Tianyi Cui d8dbb8235c refactor(acp): launch automation through the dsh acp profile
Replace the standalone @deepseek-ai/dsh-acp-demo application with dsh --profile acp plus ordered example patches. The shipped acp-app bundle owns only the protocol bridge; every example overlay now targets shared dsh-base rows instead of copying a complete application tree.

Move launcher responsibilities into the ACP snapshot harness: it materializes profile patches, links required packages, reserves stdout for JSON-RPC, observes spawn and drain failures, and escalates process teardown deterministically. The relocated control-surface fixture and the ACP/subagent integration tests now exercise the real CLI/profile path.

This commit contains authored runtime, configuration, and test changes only. Generated transcript and projection churn is deliberately left for the next commit so reviewers can inspect the migration logic without hundreds of expected-output edits.
2026-08-23 10:59:00 +08:00

11 KiB

Cookbook: extension plugin shapes

English | 中文

Reference patterns for harness extensions. The snippets omit imports and helper implementations and are not copy-paste-complete. For concrete authoring paths, see the package checklist, first-tool tutorial, tool reference, and LLM adapter guide; the architecture owns the system and extension-point map.

A tool plugin

A tool registers on ctx.tools. The annotated defineTool example (typed execute arguments, result construction, the run_in_background pattern) lives in adding-a-tool.md — that guide is the source of truth for tool definitions. Raw JSON-Schema ToolDefinitions are also accepted by ctx.tools.register() directly (that is how MCP-sourced tools arrive); defineTool is the typed helper for first-party tools.

A hook plugin (permission-gate example)

This permission gate is one example of a hook plugin. It returns a typed decision from the tools/pre-execute gate to allow or deny a call; sandbox, permission, and plan-mode plugins can use this extension point. Hook plugins can intercept other extension points and are not inherently permission gates. A "native hook" is an ordinary Cordis plugin on an interception point; it needs no external protocol.

import type { Context } from '@deepseek-ai/cordis'
import type { PreToolDecision, ToolExecution } from '@deepseek-ai/dsh-tools'

declare function isAllowed(exec: ToolExecution): Promise<boolean>

export const name = 'permission-gate'

export function apply(ctx: Context) {
  ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => {
    if (!(await isAllowed(exec))) {
      return { kind: 'deny', reason: 'Denied by policy.' }
    }
    return next()
  })
}

This waterfall is the reorderable policy layer. Use ctx.tools.guard() when an invariant needs a monotonic final denial, tools/execute when a plugin must wrap the actual dispatch lifetime (timeouts/retries/metrics; only exec.signal is replaceable), tools/post-execute for explicit result transformation, and tools/result for contained observation of the immutable final outcome. The adding-a-tool guide gives the selection rule.

A UI plugin

A UI plugin renders from the session/event feed (the assistant token stream as assistant/chunk, plus turn/step boundaries and tool activity), and drives input back in via agent.followup() / agent.steer(). A browser plugin contributing a business row to the built-in Web Client instead registers a ConversationNodeDefinition and keyed Chat renderer; follow the Conversation Node guide.

import type { Context } from '@deepseek-ai/cordis'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'

declare function render(text: string): void
declare function onUserInput(handler: (text: string) => void): void

export const name = 'my-ui'
export const inject = ['agents']

export function apply(ctx: Context) {
  ctx.on('session/event', (_session, event) => {
    if (event.type === 'assistant/chunk' && event.data.chunk.type === 'text-delta') {
      render(event.data.chunk.text)
    }
  })
  onUserInput(text => ctx.agents.get(SessionId('client-session'))?.followup(createUserMessage({
    content: [{ type: 'text', text }],
    source: { kind: 'user' },
  })))
}

An external protocol driver

A protocol driver adapts a wire peer to ctx.agents; it may serve a UI or an automation client. A stdio driver owns stdout, creates or resumes agents through the factory, and maps protocol requests to followup() or cancel(). A low-level prompt request returns its durable enqueue receipt; it does not acquire a result by correlating MessageId with turn/end. Publish whole-agent status separately. An automation method may wait from its receipt through the next idle and summarize that explicitly owned interval, while a UI normally keeps observing the open-ended event stream. Tear agents down with AgentHandle.dispose() so disposal reaches quiescence.

packages/acp/acp is the automation-only worked example: it exposes fresh text sessions over Agent Client Protocol JSON-RPC stdio, emits committed assistant text, and registers a one-shot machine permission answerer for agents it owns. Its README defines the exact methods, event order, and lifecycle contract.

import type { Context } from '@deepseek-ai/cordis'

export const name = 'my-protocol-bridge'
export const inject = ['agents', 'sessions', 'sessionPersistence']

export function apply(ctx: Context) {
  // Stream every logged assistant text/reasoning delta out to the client.
  ctx.on('session/event', (_session, event) => {
    if (event.type === 'assistant/chunk') {
      const chunk = event.data.chunk
      if (chunk.type === 'text-delta') {
        // sendToClient({ kind: 'message_chunk', text: chunk.text })
      }
    }
  })
  // Inbound "prompt": create/resume an agent, feed it, and return its enqueue receipt.
  // Whole-agent status is a separate notification; no turn end belongs to this prompt.
  // Teardown reaches quiescence via AgentHandle.dispose() (stop + await exit).
}

Runnable wirings

Runnable leaves contribute profile patches from examples/*/cordis.yml; the root demo:* scripts and those leaf directories are the authoritative inventory. The product dsh launcher owns Web, ACP, SDK, and one-shot headless execution through named profiles. The JSON-RPC leaf remains only for the temporarily held-back Python SDK runtime. The headless snapshot leaf mounts @deepseek-ai/dsh-agent-spine-demo and JSONL persistence explicitly, then drives them through an example-owned test fixture rather than a shipped app package.

The feature → mechanism map

Every product feature maps to a listener on a documented extension point — the microkernel claim made checkable (microkernel Agent Note). No row modifies the loop.

system-prompt/assemble is an expert cooperative whole-assembly transform: its returned assembly is authoritative, so listener authors own preserving active Code Mode and structured-output protocol contributions. Prefer ctx.tools.restrict() for tool filtering that must stay aligned across presentation, lookup, and execution.

Product feature Plugin mechanism
Hook system (user + project level) listeners on agent/session-start, agent/pre-step, agent/request, tools/pre-execute, tools/post-execute, and agent/turn-stopping; the waterfalls return typed decisions, while agent/turn-stopping may steer another step; the dsh-hooks-claude-code / dsh-hooks-codex bridges map hook config files onto these extension points
/goal ctx.goals owns durable state, dsh-goal-round-driver schedules same-session rounds through the public Agent, and separate command/tool producers expose human/model control
/loop on the turn/end session event, followup() the next iteration; or force-continue
Dynamic workflow ctx.workflowEngine + the worker-thread engine + the workflow tool; structured in-process children enforce output with scoped prompt/tool registrations, a monotonic tool guard, final tools/result commit (including enclosing run_code), and the structured-output execution's monotonic concludeTurn() marker
Queued + steering messages core Agent.followup() / Agent.steer()
Context compaction (auto + manual) the ctx.compaction seam + dsh-compaction-basic; automatic pressure runs on serial agent/pre-step, canonical overflow recovery runs on agent/request-error, and manual callers use the same compact service (compaction Agent Note)
System prompt configurability ctx.systemPrompt.section() with ordering and scope-local shadowing
AGENTS.md (root) a section provider reading the file
AGENTS.md (subdir, on-touch) + file-change notices agent.inject() from a watcher / tool-result listener
Built-in tools ctx.tools.register(); schemas flow into the assembly automatically — the dsh-tool-* families (bash, fs, web, subagent, todo) are the shipped examples
ToolSearch / progressive disclosure replace a scoped ctx.tools.restrict() registration as the visible set changes; the registry keeps presentation, lookup, and execution aligned
Tool deadline / retry / metrics wrap core dispatch with tools/execute; a wrapper may replace exec.signal, delegate, and inspect the normalized result in one lexical lifetime
Final tool-result metrics / audit / capture observe immutable authoritative outcomes with tools/result; use tools/post-execute instead only when the plugin must transform the result or attach context
Monotonic terminal turn policy call ToolExecution.concludeTurn() from the successful terminal tool; later tool calls in the same response remain guardable, and the loop stops after the step
Subprocess sandbox (landlock / sandbox-exec) use a ctx.sandbox backend through dsh-bash-sandbox; use tools/pre-execute for capability-level denial
Permission system / AskUserQuestion return ask from tools/pre-execute and answer through ctx.approval; register a separate model-facing ask tool for ordinary user questions
Plan mode @deepseek-ai/dsh-plan-mode — logged plan/mode state, the plan:policy guidance section, /plan [message] entry, /plan off direct exit, and the user-reviewed exit_plan_mode exit; enforcement stays on the independent sandbox/approval axes
Sub-agent delegation the ctx.subagents provider registry (dsh-subagent-spawn-in-process/-fork/-acp/-codex/-claude-code/-dsh-sdk) + dsh-tool-subagent exposing one configured provider to the model
MCP one plugin per server: discover tools → ctx.tools.register()
Skills section + tool registration; inject() skill content on invocation
Memory section provider + tool
Scheduled tasks (cron) a plugin registers model-callable scheduling tools; timer fires → followup(…, {source: {kind: 'cron', …}}) when idle / inject() notification when busy
UI (GUI; CLI emits JSONL) listen session/event (assistant chunks, boundaries, tool activity); input → followup()
Web Client Chat business node register a ConversationNodeDefinition and conversation.chat.node keyed renderer
SessionTelemetryBackend / replayable trace session/event → JSONL; replay = sessions.create(id, { seed })
Model adapters LlmAdapter subclass via registerAdapter (dsh-llm-deepseek, dsh-llm-pi-ai)
Plugin hot-reload every registration is a ctx.effect → vendored HMR just works