Add abstract service interface packages
@deepseek-ai/dsh-llm: provider-neutral content-block vocabulary (merge-extensible maps), raw StreamChunk protocol, ToolSchema, abstract LlmAdapter, LlmService adapter registry, BlockAssembler. @deepseek-ai/dsh-session: event-sourced Session (append-only log, deriveMessages; context/steering render as tagged envelopes), SessionStore, session/event + awaited session/flush durability seam. @deepseek-ai/dsh-system-prompt: ordered sections + tool-schema providers; assemble() through the system-prompt/assemble waterfall. Tool schemas are part of the assembly by design. @deepseek-ai/dsh-tools: tool registry feeding schemas into the assembly; execute() through the tools/execute waterfall (the single sandbox/permission/hook seam). @deepseek-ai/dsh-agent: Agent interface (send/steer/inject/abort, spawn/fork TODO seams), AgentRegistry, and the full agent/* event taxonomy so plugins never depend on the concrete loop.
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-system-prompt",
|
||||
"description": "System prompt assembly registry for the DeepSeek Harness",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { Context, Service } from 'cordis'
|
||||
import type { ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
systemPrompt: SystemPrompt
|
||||
}
|
||||
|
||||
interface Events {
|
||||
/** Waterfall around prompt assembly — mutate/extend the assembly. */
|
||||
'system-prompt/assemble'(this: SystemPrompt, assembly: PromptAssembly, next: () => Promise<PromptAssembly>): Promise<PromptAssembly>
|
||||
/** A section or tool provider was registered or unregistered. */
|
||||
'system-prompt/change'(): void
|
||||
}
|
||||
}
|
||||
|
||||
/** One contributed section of the system prompt. */
|
||||
export interface PromptSection {
|
||||
/** Unique name (diagnostics / dedup). */
|
||||
name: string
|
||||
/** Sections are concatenated in ascending order. */
|
||||
order: number
|
||||
/** Static text or a provider evaluated at each assembly. */
|
||||
text: string | (() => string)
|
||||
}
|
||||
|
||||
/**
|
||||
* The assembled prompt.
|
||||
*
|
||||
* Tool schemas are part of the assembly by design: "what the model is told it
|
||||
* can do" is one coherent thing managed here, even though adapters transmit
|
||||
* `tools` as a separate wire field rather than prompt text.
|
||||
*
|
||||
* Merge-extensible: plugins can declare extra fields on this interface.
|
||||
*/
|
||||
export interface PromptAssembly {
|
||||
sections: PromptSection[]
|
||||
tools: ToolSchema[]
|
||||
}
|
||||
|
||||
/** Renders the text part of an assembly (sections joined by blank lines). */
|
||||
export function renderPrompt(assembly: PromptAssembly): string {
|
||||
return assembly.sections
|
||||
.map(section => typeof section.text === 'function' ? section.text() : section.text)
|
||||
.filter(text => text.length > 0)
|
||||
.join('\n\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Registry service (`ctx.systemPrompt`): plugins contribute ordered text
|
||||
* sections and tool-schema providers; the agent loop calls `assemble()` once
|
||||
* per step.
|
||||
*/
|
||||
export class SystemPrompt extends Service {
|
||||
private sections: PromptSection[] = []
|
||||
private toolProviders: (() => ToolSchema[])[] = []
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'systemPrompt')
|
||||
}
|
||||
|
||||
/** Contribute a section. Disposed with the calling fiber. */
|
||||
section(section: PromptSection): () => void {
|
||||
return this.ctx.effect(() => {
|
||||
this.sections.push(section)
|
||||
this.ctx.emit('system-prompt/change')
|
||||
return () => {
|
||||
const index = this.sections.indexOf(section)
|
||||
if (index >= 0) this.sections.splice(index, 1)
|
||||
this.ctx.emit('system-prompt/change')
|
||||
}
|
||||
}, 'systemPrompt.section()')
|
||||
}
|
||||
|
||||
/** Contribute tool schemas (evaluated at each assembly). Disposed with the fiber. */
|
||||
tools(provider: () => ToolSchema[]): () => void {
|
||||
return this.ctx.effect(() => {
|
||||
this.toolProviders.push(provider)
|
||||
this.ctx.emit('system-prompt/change')
|
||||
return () => {
|
||||
const index = this.toolProviders.indexOf(provider)
|
||||
if (index >= 0) this.toolProviders.splice(index, 1)
|
||||
this.ctx.emit('system-prompt/change')
|
||||
}
|
||||
}, 'systemPrompt.tools()')
|
||||
}
|
||||
|
||||
/** Assemble the current prompt (sections sorted, tools collected). */
|
||||
assemble(): Promise<PromptAssembly> {
|
||||
const assembly: PromptAssembly = {
|
||||
sections: [...this.sections].sort((a, b) => a.order - b.order),
|
||||
tools: this.toolProviders.flatMap(provider => provider()),
|
||||
}
|
||||
return this.ctx.waterfall(this, 'system-prompt/assemble', assembly, async () => assembly)
|
||||
}
|
||||
}
|
||||
|
||||
export default SystemPrompt
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import SystemPrompt, { PromptAssembly, renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||
|
||||
describe('SystemPrompt', () => {
|
||||
it('assembles sections in order with dynamic text and collected tools', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
|
||||
ctx.systemPrompt.section({ name: 'persona', order: 0, text: 'You are DeepSeek Code.' })
|
||||
ctx.systemPrompt.section({ name: 'cwd', order: 20, text: () => `cwd: /tmp` })
|
||||
ctx.systemPrompt.section({ name: 'rules', order: 10, text: 'Be precise.' })
|
||||
ctx.systemPrompt.tools(() => [{ name: 'echo', description: 'echo back', parameters: {} }])
|
||||
|
||||
const assembly = await ctx.systemPrompt.assemble()
|
||||
expect(assembly.sections.map(s => s.name)).toEqual(['persona', 'rules', 'cwd'])
|
||||
expect(assembly.tools).toEqual([{ name: 'echo', description: 'echo back', parameters: {} }])
|
||||
expect(renderPrompt(assembly)).toBe('You are DeepSeek Code.\n\nBe precise.\n\ncwd: /tmp')
|
||||
})
|
||||
|
||||
it('removes contributions when the contributing fiber is disposed (HMR safety)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
inner.systemPrompt.section({ name: 'scoped', order: 0, text: 'scoped section' })
|
||||
inner.systemPrompt.tools(() => [{ name: 'scoped-tool', description: '', parameters: {} }])
|
||||
}, { inject: ['systemPrompt'] }))
|
||||
|
||||
expect((await ctx.systemPrompt.assemble()).sections).toHaveLength(1)
|
||||
await fiber.dispose()
|
||||
const assembly = await ctx.systemPrompt.assemble()
|
||||
expect(assembly.sections).toHaveLength(0)
|
||||
expect(assembly.tools).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('composes multiple system-prompt/assemble waterfall listeners in order', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
ctx.systemPrompt.section({ name: 'base', order: 0, text: 'base' })
|
||||
|
||||
// Listener A appends a section, then delegates.
|
||||
ctx.on('system-prompt/assemble', async (assembly: PromptAssembly, next) => {
|
||||
assembly.sections.push({ name: 'from-a', order: 100, text: 'a' })
|
||||
return next()
|
||||
})
|
||||
// Listener B (registered later, runs after A) sees A's contribution.
|
||||
const seen: string[][] = []
|
||||
ctx.on('system-prompt/assemble', async (assembly: PromptAssembly, next) => {
|
||||
seen.push(assembly.sections.map(s => s.name))
|
||||
return next()
|
||||
})
|
||||
|
||||
const assembly = await ctx.systemPrompt.assemble()
|
||||
expect(seen).toEqual([['base', 'from-a']])
|
||||
expect(assembly.sections.map(s => s.name)).toEqual(['base', 'from-a'])
|
||||
})
|
||||
|
||||
it('lets a waterfall listener short-circuit by not calling next()', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
ctx.systemPrompt.section({ name: 'real', order: 0, text: 'real' })
|
||||
|
||||
ctx.on('system-prompt/assemble', async () => {
|
||||
return { sections: [], tools: [] } satisfies PromptAssembly
|
||||
})
|
||||
|
||||
const assembly = await ctx.systemPrompt.assemble()
|
||||
expect(assembly.sections).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{ "path": "../../vendor/cosmokit" },
|
||||
{ "path": "../../vendor/cordis" },
|
||||
{ "path": "../llm" }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user