DeepSeek Harness Plugin Architecture: Services for Shared Logic & Event Listeners for Flow Hooks
This article explains DeepSeek Harness's two core extension mechanisms: services for reusable logic shared across plugins, and event listeners (ctx.on) for observing, intercepting, and rewriting execution flows, with complete code examples for a summarization service and a guardrail plugin.
Part 1: Services — Shared Capabilities Across Plugins
DeepSeek Harness plugins run on Cordis, which manages plugin loading and lifecycles. Each plugin mounts logic onto a shared context ( ctx) via apply(ctx). When multiple plugins need the same logic (e.g., summarizing a session), copying code into every tool creates duplication. The solution: extract the logic into a service — a named capability mounted on ctx — and let tools become thin wrappers that merely translate parameters and delegate to the service.
1.1 Why Services Exist
Services solve logic reuse. Instead of two tools each implementing summarization, a single SummaryService exposes summarize(sessionId, options). Tools A and B simply call ctx.summaryService.summarize(...). The official boundary: tools are for the model; services are for in-process plugins and tools . Tools are single-execution entry points; services can expose arbitrary methods. Harness's /packages/core already provides six built-in services (e.g., sessions, llm) that plugins depend on.
1.2 Minimal Service Boilerplate
import { Context, Service } from '@deepseek-ai/cordis'
declare module '@deepseek-ai/cordis' {
interface Context {
myService: MyService
}
}
export class MyService extends Service {
static inject = ['sessions']
constructor(ctx: Context) {
super(ctx, 'myService')
}
async summarize(sessionId: string): Promise<string> {
return '...'
}
}
export async function apply(ctx: Context) {
await ctx.plugin(MyService)
} super(ctx, 'myService')registers the service; after construction ctx.myService is available. async summarize(...) is the callable method.
Service subclasses are still plugins, so they need an apply that calls ctx.plugin(MyService).
Consumers declare dependencies via inject: ['myService'] — no import of the service class required. Cordis guarantees apply runs only after all injected services are ready; load order is determined by the dependency graph, not file order. If a provider is unloaded, dependents are auto-disposed and reloaded when the service reappears — no dangling calls.
1.3 Callable Services via [Service.invoke]
If a service represents a single action rather than a method collection, implement a getter for [Service.invoke]:
export class Logger extends Service {
get [Service.invoke]() {
return (msg: string) => this.log(msg)
}
log(msg: string) { /* ... */ }
}Then ctx.logger('hello') equals ctx.logger.log('hello'). Harness's built-in ctx.logger() uses this pattern. Other lifecycle hooks exist ( [Service.init], [Service.check]); see the docs at https://deepseekdocs.com/docs/learn/dev/write-service.
1.4 Practical Example: Summarization Service + Thin Tools
Scenario: Two entry points — a quick summary tool and a detailed archive tool that also writes a file — plus future background auto-summarization. All share the same core logic: read history, build prompt, call LLM, return summary.
Step 1: Create ~/deepseek-harness/scratch-plugin/src/ and a cordis.yml overlay; launch with pnpm dsh web --patch ./scratch-plugin/cordis.yml.
Step 2 — summary-service.ts :
// scratch-plugin/src/summary-service.ts
import { Service } from '@deepseek-ai/cordis'
import type { Context } from '@deepseek-ai/cordis'
import { BlockAssembler, createUserMessage } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
declare module '@deepseek-ai/cordis' {
interface Context {
summaryService: SummaryService
}
}
export interface SummaryOptions {
maxLength?: number
style?: 'brief' | 'detailed'
forceRefresh?: boolean
}
const MAX_TRANSCRIPT_CHARS = 24_000
const MAX_MESSAGE_CHARS = 2_000
function messageText(blocks: readonly ContentBlock[]): string {
const parts: string[] = []
for (const block of blocks) {
if (block.type === 'text') parts.push(block.text)
}
return parts.join('
').trim()
}
function clamp(text: string, limit: number): string {
return text.length <= limit ? text : `${text.slice(0, limit)}…`
}
function renderTranscript(history: readonly Message[]): string {
const lines: string[] = []
for (const message of history) {
const text = messageText(message.content)
if (text.length === 0) continue
lines.push(`${message.role.toUpperCase()}: ${clamp(text, MAX_MESSAGE_CHARS)}`)
}
const joined = lines.join('
')
if (joined.length <= MAX_TRANSCRIPT_CHARS) return joined
// Keep the tail: latest turns describe current work state.
return `…
${joined.slice(joined.length - MAX_TRANSCRIPT_CHARS)}`
}
function finishError(finish: { kind: string; failure?: { message?: string; code?: string } }): Error | undefined {
switch (finish.kind) {
case 'stop': return undefined
case 'error':
case 'aborted': {
const error = new Error(finish.failure?.message ?? '摘要模型调用失败')
if (finish.failure?.code !== undefined) (error as Error & { code?: string }).code = finish.failure.code
return error
}
case 'max-tokens': return new Error('摘要输出达到 maxTokens 上限,请调大 max_length')
default: return new Error(`摘要模型返回了不支持的结束原因 "${finish.kind}"`)
}
}
export class SummaryService extends Service {
static inject = ['sessions', 'llm']
private cache = new Map<string, string>()
constructor(ctx: Context) {
super(ctx, 'summaryService')
}
async summarize(sessionId: string, options: SummaryOptions = {}): Promise<string> {
const { maxLength = 200, style = 'brief', forceRefresh = false } = options
const cacheKey = `${sessionId}:${style}:${maxLength}`
if (!forceRefresh && this.cache.has(cacheKey)) {
return this.cache.get(cacheKey)!
}
const session = this.ctx.sessions.get(SessionId(sessionId))
if (session === undefined) throw new Error(`summary: 找不到会话 ${sessionId}`)
const header = session.requestHeader()
const provider = header?.config.provider
const model = header?.config.model
if (provider === undefined || model === undefined) {
throw new Error(`summary: 会话 ${sessionId} 还没有 request/header,无法确定 provider/model`)
}
const transcript = renderTranscript(session.deriveMessages())
if (transcript.length === 0) throw new Error(`summary: 会话 ${sessionId} 没有可总结的文本消息`)
const system = style === 'brief'
? `用不超过 ${maxLength} 字的中文总结下面这段 AI 编码助手会话:概括用户的诉求、已完成的工作、得到的结论和尚未解决的问题。只输出摘要正文。`
: '详细总结下面这段 AI 编码助手会话,用中文分节写出:会话目标、按轮次的关键动作与工具调用、发现的问题与根因、当前状态、未完成事项。只输出摘要正文。'
const maxTokens = style === 'brief'
? Math.min(4_096, Math.max(512, maxLength * 6))
: 4_096
const messages: Message[] = [createUserMessage({
content: [{ type: 'text', text: transcript }],
source: { kind: 'plugin', plugin: 'scratch-plugin/summary-service' },
})]
const request: GenerateOptions = { provider, model, messages, system, maxTokens, sessionId: session.id }
const assembler = new BlockAssembler()
for await (const chunk of this.ctx.llm.stream(request)) assembler.push(chunk)
const finish = assembler.finish
const summary = assembler.blocks()
.filter((block): block is Extract<ContentBlock, { type: 'text' }> => block.type === 'text')
.map(block => block.text)
.join('
')
.trim()
if (finish.kind !== 'stop' && finish.kind !== 'max-tokens') {
throw finishError(finish) ?? new Error('summary: 摘要模型调用失败')
}
if (summary.length === 0) {
throw finishError(finish) ?? new Error('summary: 摘要模型没有产出文本')
}
this.cache.set(cacheKey, summary)
return summary
}
invalidate(sessionId: string) {
for (const key of this.cache.keys()) {
if (key.startsWith(`${sessionId}:`)) this.cache.delete(key)
}
}
}
export async function apply(ctx: Context) {
await ctx.plugin(SummaryService)
}static inject = ['sessions', 'llm'] ensures the service waits for those built-ins. After super(ctx, 'summaryService') , ctx.summaryService is mounted; other plugins inject it via inject: ['summaryService'] . Step 3 — summary-tools.ts (thin wrappers): <code>// scratch-plugin/src/summary-tools.ts import { writeFile } from 'node:fs/promises' import type { Context } from '@deepseek-ai/cordis' import { defineTool } from '@deepseek-ai/dsh-tools' export const name = 'summary-tools' export const inject = ['summaryService', 'tools'] export function apply(ctx: Context) { // Tool 1: quick summary ctx.tools.register(defineTool({ name: 'summarize_session', description: '总结指定会话的内容', parameters: { session_id: { type: 'string', required: true, description: '要总结的会话 ID' }, max_length: { type: 'number', description: '摘要最大字数,默认 200' }, }, output: { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: value }], }, async execute(args) { const summary = await ctx.summaryService.summarize(args.session_id, { maxLength: args.max_length, }) return summary }, })) // Tool 2: detailed archive + file write ctx.tools.register(defineTool({ name: 'archive_session', description: '生成会话的详细归档摘要并保存到文件', parameters: { session_id: { type: 'string', required: true }, output_path: { type: 'string', required: true, description: '归档文件的写入路径' }, }, output: { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: value }], }, async execute(args) { const summary = await ctx.summaryService.summarize(args.session_id, { style: 'detailed', forceRefresh: true, }) await writeFile(args.output_path, `${summary}\n`, 'utf8') return `归档摘要已写入 ${args.output_path}` }, })) }</code> inject: ['summaryService', 'tools'] guarantees the tool plugin runs after the service and the tools runtime are ready. defineTool handles type inference and validation; execute returns a string that render wraps into a result card. Step 4 — cordis.yml : <code>- insert: - id: summary-service name: '/home/sandui/deepseek-harness/scratch-plugin/src/summary-service.ts' - id: summary-tools name: '/home/sandui/deepseek-harness/scratch-plugin/src/summary-tools.ts'</code> Step 5 — Launch & Verify: Run the patched command, create a session in the Web UI, then ask the model to call summarize_session or archive_session with the session ID. Both tools delegate to the single SummaryService , demonstrating the "service does logic, tool is a thin shell" pattern. Part 2: Event Listeners — Hooking Into Execution Flows Harness provides numerous hooks via ctx.on(event, handler) , letting plugins observe, intercept, rewrite, or short-circuit lifecycle events (session, tool, agent, cross-cutting). 2.1 Basic Shapes <code>import { Context } from '@deepseek-ai/cordis' export const name = 'event-watcher' export function apply(ctx: Context) { // Observe-only: session created ctx.on('session/created', (session) => { console.log('New session:', session.id) }) // Gatekeeper: pre-execute interception ctx.on('tools/pre-execute', async (execution, next) => { if (execution.name === 'forbidden_tool') { return { kind: 'deny', reason: 'This tool is forbidden by policy' } } return next() // pass to downstream }) // Rewriter: post-execute transformation ctx.on('tools/post-execute', async (execution, result, next) => { const decision = await next() // inspect result, augment, or modify decision return decision }) }</code> Handlers registered via ctx.on are automatically cleaned up when the plugin unloads — Cordis treats registration as a reversible side effect. 2.2 Event Dispatch Modes Different events use different dispatch modes, which dictate handler signatures and behavior: emit — No await; sequential, observe-only; no return value; typical: agent/error (logging) waterfall — No await; chained via next() (onion model); returns value; typical: tools/pre-execute (gatekeeping) parallel — Await; all parallel, wait for slowest; no return value; typical: session/flush serial — Await; sequential, await each; returns value; typical: some agent ordered steps bail — No await; sequential, stop at first truthy return; returns value; typical: short-circuit decisions The waterfall mode is the most critical. It implements the "onion model" (like Koa/Express middleware): each handler receives (...args, next) ; calling await next() passes control inward; the return value flows back outward for post-processing. Forgetting next() in a waterfall handler accidentally blocks all downstream listeners and default logic — a common bug. Both tools/pre-execute (gatekeeper) and tools/post-execute (rewriter) use waterfall. 2.3 Practical Example: Guardrail Plugin Typical guardrails do two things: (1) pre-execute block dangerous commands (e.g., shell_execute with rm -rf ), (2) post-execute redact secrets (API keys, emails, phone numbers) from successful results. Step 1 — pre-execute Gatekeeper (decision-type, can short-circuit): <code>// guardrail.ts export const name = 'guardrail' const DANGEROUS_PATTERNS = [/rm\s+-rf\s+\//, /mkfs\./, /dd\s+if=/] export function apply(ctx: Context) { ctx.on('tools/pre-execute', async (execution, next) => { if (execution.name === 'shell_execute') { const command: string = execution.args.command ?? '' if (DANGEROUS_PATTERNS.some(p => p.test(command))) { return { kind: 'deny', reason: `Blocked dangerous command: ${command}` } } } return next() // no match → pass downstream }) }</code> Key line: return next() . In waterfall, the handler is one onion layer; next() delegates downstream. Decision-type handlers short-circuit by returning a value without calling next() — that's the design intent. But if you only want to observe and forget next() , you silently kill the rest of the chain. Step 2 — post-execute Rewriter (transform successful results): <code>const SECRET_PATTERNS: Array<[RegExp, string]> = [ [/sk-[A-Za-z0-9]{20,}/g, 'sk-***REDACTED***'], [/[\w.+-]+@[\w-]+\.[\w.]+/g, '***@***.***'], [/1[3-9]\d{9}/g, '1**********'], ] function redact(text: string): string { return SECRET_PATTERNS.reduce((acc, [re, mask]) => acc.replace(re, mask), text) } export function apply(ctx: Context) { ctx.on('tools/post-execute', async (execution, result, next) => { const decision = await next() // let downstream (default result assembly) run first if (decision.kind === 'allow' && typeof decision.content === 'string') { return { ...decision, content: redact(decision.content) } } return decision // deny or non-string → pass through unchanged }) }</code> This is the classic waterfall "wrap" pattern: await next() peels to the core, gets the downstream decision, then modifies on the way out. The decision.kind === 'allow' guard ensures we don't redact a denied/empty result. Summary Services provide capabilities : extract reusable logic into a single service; tools become thin parameter-translation shells. State lives in the service (e.g., caching). Event listeners insert behavior : via ctx.on hooks you can observe, intercept, rewrite, or short-circuit flows without touching existing code. Most behavior extensions use ctx.on , not code modification. This completes the plugin mechanics foundation: loading, lifecycle, services, and events. Future articles will cover practical daily workflows with DeepSeek Harness.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Fun with Large Models
Master's graduate from Beijing Institute of Technology, published four top‑journal papers, previously worked as a developer at ByteDance and Alibaba. Currently researching large models at a major state‑owned enterprise. Committed to sharing concise, practical AI large‑model development experience, believing that AI large models will become as essential as PCs in the future. Let's start experimenting now!
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
