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.
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 |