9.9 KiB
Cookbook: extension plugin shapes
English | 中文
FIXME: This important guide has not received sufficient human design review; complete that review before the first release.
The three plugin shapes you write against the harness extension surface, as illustrative snippets (elided imports and helper stubs — not copy-paste-complete). For the full step-by-step guides see adding a package, adding a tool, and adding an LLM adapter; for the seams these hook into see docs/architecture.md.
A tool plugin
A tool registers on ctx.tools. The annotated defineTool example (typed execute args, result shaping, the run_in_background pattern) lives in adding-a-tool.md — that guide is the source of truth for the tool shape. Raw JSON-Schema ToolDefinitions are also accepted by ctx.tools.register() directly (that is how MCP-sourced tools arrive); defineTool is the typed sugar 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 seam. Hook plugins can intercept other seams and are not inherently permission gates. A "native hook" is an ordinary Cordis plugin on an interception seam; it needs no external protocol.
import type { Context } from '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.send() / agent.steer().
import type { Context } from 'cordis'
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'))?.send([{ type: 'text', text }]))
}
A client-driver plugin (external protocol bridge)
A client driver is a UI plugin for a wire-protocol peer. It owns stdio, so stdout logging must be disabled, creates or resumes agents through the factory, maps harness events to protocol messages, and maps requests to send() or cancel(). Settle each request exactly once from durable turn/end, even if rendering fails, and tear agents down with AgentHandle.dispose() so disposal reaches quiescence.
packages/ui/acp is the worked example: it bridges the agent to the Agent Client Protocol (JSON-RPC over stdio) so Zed and other ACP editors can drive it. See its README for the full method surface and the permission-prompt answerer it registers on the approval seam.
import type { Context } from '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 and feed it; settle on turn end.
// Teardown reaches quiescence via AgentHandle.dispose() (stop + await exit).
}
Runnable wirings
Runnable leaves load their plugin trees from examples/*/cordis.yml; the root demo:* scripts and those leaf directories are the authoritative inventory. Interactive leaves use @deepseek-ai/dsh-tui-demo, non-interactive leaves use @deepseek-ai/dsh-cli-demo, ACP leaves use @deepseek-ai/dsh-acp-demo, and the app packages share @deepseek-ai/dsh-agent-spine-demo.
The feature → mechanism map
Every product feature maps to a listener on a documented extension seam — 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/prompt-submit, agent/request, agent/step-result, tools/pre-execute, tools/post-execute, agent/turn-continuation — each interception waterfall returns a typed Decision; the dsh-hooks-claude / dsh-hooks-codex bridges map hook config files onto these seams |
/goal |
ctx.goals owns durable state, dsh-goal-session schedules same-session rounds through the public Agent, and separate command/tool producers expose human/model control |
/loop |
on the turn/end session event, send() the next iteration; or force-continue |
| Dynamic workflow | ctx.workflows + 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 terminal agent/turn-stop |
| Queued + steering messages | core Agent.send() / Agent.steer() |
| Context compaction (auto + manual) | the ctx.compact seam + dsh-compact-basic; automatic pressure runs on serial agent/post-step, canonical overflow recovery runs on agent/request-error, and manual callers use the same compact service (compaction Agent Note — the model-facing /compact consumer tool is deferred) |
| 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 | return { action: 'stop' } from serial agent/turn-stop, after continuation and steering have already been folded |
| 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 | Shipped: @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/-fork/-acp) + 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 → send(…, {source: {kind: 'cron', …}}) when idle / inject() notification when busy |
| UI (GUI; CLI emits JSONL) | listen session/event (assistant chunks, boundaries, tool activity); input → send() |
| Telemetry / 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 |