diff --git a/packages/agent/package.json b/packages/agent/package.json new file mode 100644 index 0000000000..478e461e13 --- /dev/null +++ b/packages/agent/package.json @@ -0,0 +1,32 @@ +{ + "name": "@deepseek-ai/dsh-agent", + "description": "Agent interface, registry, and event vocabulary 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", + "@deepseek-ai/dsh-session": "^0.0.1", + "cordis": "^4.0.0-rc.6" + }, + "devDependencies": { + "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts new file mode 100644 index 0000000000..df232fd0f9 --- /dev/null +++ b/packages/agent/src/index.ts @@ -0,0 +1,49 @@ +import { Context, Service } from 'cordis' +import type { Agent } from './types.ts' + +export * from './types.ts' + +declare module 'cordis' { + interface Context { + agents: AgentRegistry + } +} + +/** + * Agent registry (`ctx.agents`): tracks live agents so UI, hook, and + * orchestrator plugins can find them without depending on the concrete loop + * package. Agent *creation* belongs to whichever plugin implements the Agent + * interface (phase 1: `@deepseek-ai/dsh-agent-loop`). + */ +export class AgentRegistry extends Service { + private store = new Map() + + constructor(ctx: Context) { + super(ctx, 'agents') + } + + /** Register a live agent. Disposed with the calling fiber. */ + register(agent: Agent): () => void { + return this.ctx.effect(() => { + if (this.store.has(agent.id)) { + throw new Error(`agent "${agent.id}" is already registered`) + } + this.store.set(agent.id, agent) + this.ctx.emit('agent/created', agent) + return () => { + this.store.delete(agent.id) + this.ctx.emit('agent/disposed', agent) + } + }, 'agents.register()') + } + + get(id: string): Agent | undefined { + return this.store.get(id) + } + + list(): Agent[] { + return [...this.store.values()] + } +} + +export default AgentRegistry diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts new file mode 100644 index 0000000000..f66137cdc7 --- /dev/null +++ b/packages/agent/src/types.ts @@ -0,0 +1,107 @@ +import type { ContentBlock, GenerateOptions, Message, MessageSource, StreamChunk } from '@deepseek-ai/dsh-llm' +import type { Session, TurnEndReason } from '@deepseek-ai/dsh-session' + +/** + * Options an agent is created with. + * Merge-extensible: plugins declare extra fields via declaration merging. + */ +export interface AgentOptions { + /** Model name (must have a registered adapter at call time). */ + model?: string + /** Per-agent system prompt appended after the assembled sections. */ + systemPrompt?: string +} + +export interface SendOptions { + source?: MessageSource +} + +export type AgentStatus = 'idle' | 'running' | 'disposed' + +/** + * The agent handle — the surface every plugin (UI, hooks, orchestrators) + * programs against. The concrete implementation lives in + * `@deepseek-ai/dsh-agent-loop` (class `LoopAgent`); nothing outside the loop + * package should depend on the implementation. + */ +export interface Agent { + readonly id: string + readonly options: AgentOptions + readonly session: Session + readonly status: AgentStatus + + /** Queue a user message. Starts a turn when idle; otherwise waits for the next turn. */ + send(content: ContentBlock[], options?: SendOptions): void + + /** + * Steer a running turn: content is injected between steps of the current + * turn. When idle, behaves like {@link send}. + */ + steer(content: ContentBlock[], options?: SendOptions): void + + /** + * Inject in-session context (file-change notices, skill content, cron + * notifications, …): appends a `context/message` session event without + * triggering a turn — the next model request sees it at its chronological + * position, rendered as tagged synthetic context rather than a user prompt. + * + * TODO(review): exact envelope/rendering rules live in dsh-session and need + * review once a real adapter exists. + */ + inject(content: ContentBlock[], options?: SendOptions): void + + /** Abort the in-flight step (if any); the turn ends with reason 'aborted'. */ + abort(reason?: string): void + + // TODO(sub-agents): spawn/fork seams — semantics deliberately deferred. + // The intended shape: a creation option referencing a parent agent + // (fork = seed the child Session with the parent's event log; spawn = + // fresh Session), with the child returned as an Agent handle so steer() + // and event subscription work uniformly. See docs/architecture.md. +} + +declare module 'cordis' { + interface Events { + // ---- lifecycle (emit) ---- + /** An agent was registered. */ + 'agent/created'(agent: Agent): void + /** An agent was disposed. */ + 'agent/disposed'(agent: Agent): void + /** Agent status changed (idle/running/disposed). */ + 'agent/status'(agent: Agent, status: AgentStatus): void + /** A message entered the agent's inbox (queued or steering). */ + 'agent/queued'(agent: Agent, content: ContentBlock[], options: SendOptions & { steering: boolean }): void + + // ---- turn/step boundaries (emit) ---- + 'agent/turn-start'(agent: Agent, turn: number): void + 'agent/turn-end'(agent: Agent, turn: number, reason: TurnEndReason): void + 'agent/step-start'(agent: Agent, turn: number, step: number): void + 'agent/step-end'(agent: Agent, turn: number, step: number): void + + // ---- interception seams (waterfall) ---- + /** + * Waterfall: mutate the fully-assembled GenerateOptions before the model + * call (hooks, compaction, model switching, tool filtering, …). + */ + 'agent/request'(agent: Agent, turn: number, step: number, options: GenerateOptions, next: () => Promise): Promise + /** + * Waterfall: post-process the assembled assistant message before tool + * dispatch (validation, content rewriting, …). + */ + 'agent/step-result'(agent: Agent, turn: number, step: number, message: Message, next: () => Promise): Promise + /** + * Waterfall: override the turn-continuation decision. The default + * (computed by the loop) is `hadToolCalls || steeringInjected`. Listeners + * can force-continue (/goal, /loop) or force-stop (budget guards). + */ + 'agent/turn-continuation'(agent: Agent, turn: number, defaultDecision: boolean, next: () => Promise): Promise + + // ---- streaming + tool notifications (emit) ---- + /** A raw stream chunk arrived (token-level UI/log feed). */ + 'agent/stream-chunk'(agent: Agent, turn: number, step: number, chunk: StreamChunk): void + /** Steering content was injected into a running turn. */ + 'agent/steering'(agent: Agent, turn: number, content: ContentBlock[]): void + /** A step or turn errored. */ + 'agent/error'(agent: Agent, turn: number, step: number, error: Error): void + } +} diff --git a/packages/agent/tests/agent.spec.ts b/packages/agent/tests/agent.spec.ts new file mode 100644 index 0000000000..a90918044a --- /dev/null +++ b/packages/agent/tests/agent.spec.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { Session } from '@deepseek-ai/dsh-session' +import AgentRegistry, { Agent } from '@deepseek-ai/dsh-agent' + +function stubAgent(id: string): Agent { + return { + id, + options: {}, + session: new Session(`${id}-session`), + status: 'idle', + send() {}, + steer() {}, + inject() {}, + abort() {}, + } +} + +describe('AgentRegistry', () => { + it('registers agents and emits created/disposed events', async () => { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + + const created: string[] = [] + const disposed: string[] = [] + ctx.on('agent/created', agent => void created.push(agent.id)) + ctx.on('agent/disposed', agent => void disposed.push(agent.id)) + + const agent = stubAgent('a1') + const dispose = ctx.agents.register(agent) + expect(created).toEqual(['a1']) + expect(ctx.agents.get('a1')).toBe(agent) + expect(ctx.agents.list()).toEqual([agent]) + + dispose() + expect(disposed).toEqual(['a1']) + expect(ctx.agents.get('a1')).toBeUndefined() + }) + + it('rejects duplicate ids and unregisters on fiber dispose (HMR safety)', async () => { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + ctx.agents.register(stubAgent('main')) + expect(() => ctx.agents.register(stubAgent('main'))).toThrow('already registered') + + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + inner.agents.register(stubAgent('scoped')) + }, { inject: ['agents'] })) + expect(ctx.agents.list().map(a => a.id)).toEqual(['main', 'scoped']) + + await fiber.dispose() + expect(ctx.agents.list().map(a => a.id)).toEqual(['main']) + }) +}) diff --git a/packages/agent/tsconfig.json b/packages/agent/tsconfig.json new file mode 100644 index 0000000000..0806132292 --- /dev/null +++ b/packages/agent/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": ["src"], + "references": [ + { "path": "../../vendor/cosmokit" }, + { "path": "../../vendor/cordis" }, + { "path": "../llm" }, + { "path": "../session" } + ] +} diff --git a/packages/llm/package.json b/packages/llm/package.json new file mode 100644 index 0000000000..317edc7ac2 --- /dev/null +++ b/packages/llm/package.json @@ -0,0 +1,28 @@ +{ + "name": "@deepseek-ai/dsh-llm", + "description": "Provider-neutral LLM service interface 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": { + "cordis": "^4.0.0-rc.6" + }, + "devDependencies": { + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/llm/src/assembler.ts b/packages/llm/src/assembler.ts new file mode 100644 index 0000000000..3cd087dbc9 --- /dev/null +++ b/packages/llm/src/assembler.ts @@ -0,0 +1,115 @@ +import type { ContentBlock, FinishReason, GenerateResult, Message, StreamChunk, TokenUsage } from './types.ts' + +/** + * Incrementally assembles raw {@link StreamChunk}s into complete + * {@link ContentBlock}s and a final assistant {@link Message}. + * + * This is the single shared assembly implementation: the agent loop feeds it + * while logging raw chunks for replay fidelity, and `LlmService.generate()` / + * `streamBlocks()` use it to offer assembled views of the same stream. + */ +export class BlockAssembler { + private partials = new Map() + + private order: number[] = [] + private _usage: TokenUsage | undefined + private _finish: FinishReason | undefined + + /** + * Feed one chunk. Returns the completed block when the chunk closes one + * (either an explicit `block-end` or an implicit close), otherwise undefined. + */ + push(chunk: StreamChunk): ContentBlock | undefined { + switch (chunk.type) { + case 'block-start': { + if (!this.partials.has(chunk.index)) this.order.push(chunk.index) + this.partials.set(chunk.index, { + blockType: chunk.blockType, + text: '', + toolCallArguments: '', + }) + return + } + case 'text-delta': + case 'reasoning-delta': { + const partial = this.ensure(chunk.index, chunk.type === 'text-delta' ? 'text' : 'reasoning') + partial.text += chunk.text + return + } + case 'tool-call-delta': { + const partial = this.ensure(chunk.index, 'tool-call') + partial.toolCallId = chunk.id + if (chunk.name) partial.toolCallName = chunk.name + partial.toolCallArguments += chunk.argumentsDelta + return + } + case 'block-end': { + const partial = this.ensure(chunk.index, chunk.block.type) + partial.block = chunk.block + return chunk.block + } + case 'usage': { + this._usage = chunk.usage + return + } + case 'finish': { + this._finish = chunk.reason + return + } + } + } + + private ensure(index: number, blockType: string) { + let partial = this.partials.get(index) + if (!partial) { + partial = { blockType, text: '', toolCallArguments: '' } + this.partials.set(index, partial) + this.order.push(index) + } + return partial + } + + /** Assemble all blocks seen so far, in stream order. */ + blocks(): ContentBlock[] { + return this.order.map((index) => { + const partial = this.partials.get(index)! + if (partial.block) return partial.block + switch (partial.blockType) { + case 'text': return { type: 'text', text: partial.text } + case 'reasoning': return { type: 'reasoning', text: partial.text } + case 'tool-call': return { + type: 'tool-call', + id: partial.toolCallId ?? `call-${index}`, + name: partial.toolCallName ?? '', + arguments: partial.toolCallArguments, + } + default: throw new Error(`cannot assemble incomplete block of type "${partial.blockType}"`) + } + }) + } + + get usage(): TokenUsage | undefined { + return this._usage + } + + get finish(): FinishReason { + return this._finish ?? { kind: 'stop' } + } + + /** The assembled assistant message. */ + message(): Message { + return { role: 'assistant', content: this.blocks() } + } + + /** The assembled non-streaming result. */ + result(): GenerateResult { + return { message: this.message(), usage: this._usage, finish: this.finish } + } +} diff --git a/packages/llm/src/index.ts b/packages/llm/src/index.ts new file mode 100644 index 0000000000..6d838cd07e --- /dev/null +++ b/packages/llm/src/index.ts @@ -0,0 +1,108 @@ +import { Context, Service } from 'cordis' +import type { ContentBlock, GenerateOptions, GenerateResult, StreamChunk } from './types.ts' +import { BlockAssembler } from './assembler.ts' + +export * from './types.ts' +export { BlockAssembler } from './assembler.ts' + +declare module 'cordis' { + interface Context { + llm: LlmService + } + + interface Events { + /** Waterfall around every streaming model call (retry, caching, routing). */ + 'llm/stream'(this: LlmService, options: GenerateOptions, next: () => AsyncIterable): AsyncIterable + /** Waterfall around every non-streaming model call. */ + 'llm/generate'(this: LlmService, options: GenerateOptions, next: () => Promise): Promise + /** An adapter was registered or unregistered. */ + 'llm/adapter-change'(): void + } +} + +export class LlmError extends Error { + constructor(message: string, public code: string) { + super(message) + this.name = 'LlmError' + } +} + +/** + * Base class for LLM provider adapters. + * + * An adapter translates between the harness vocabulary (Message/ContentBlock/ + * StreamChunk) and one provider's wire format. Adapters register themselves + * via `ctx.llm.registerAdapter(models, adapter)`. + * + * TODO: the first real adapter (DeepSeek V4) lands in a later phase; until + * then only mock adapters (tests, demo) exist. + */ +export abstract class LlmAdapter { + /** Stream one model call as raw chunks. The only required method. */ + abstract stream(options: GenerateOptions): AsyncIterable +} + +/** + * The abstract `llm` service: an adapter registry plus streaming / + * non-streaming call surfaces, both interceptable via waterfall events. + */ +export class LlmService extends Service { + private adapters = new Map() + + constructor(ctx: Context) { + super(ctx, 'llm') + } + + /** Register an adapter for the given model names. Disposed with the fiber. */ + registerAdapter(models: string[], adapter: LlmAdapter): () => void { + return this.ctx.effect(() => { + for (const model of models) this.adapters.set(model, adapter) + this.ctx.emit('llm/adapter-change') + return () => { + for (const model of models) this.adapters.delete(model) + this.ctx.emit('llm/adapter-change') + } + }, 'llm.registerAdapter()') + } + + /** Model names with a registered adapter. */ + models(): string[] { + return [...this.adapters.keys()] + } + + private adapter(model: string): LlmAdapter { + const adapter = this.adapters.get(model) + if (!adapter) throw new LlmError(`no adapter registered for model "${model}"`, 'NO_ADAPTER') + return adapter + } + + /** Stream one model call as raw chunks (token-level deltas). */ + stream(options: GenerateOptions): AsyncIterable { + return this.ctx.waterfall(this, 'llm/stream', options, () => { + return this.adapter(options.model).stream(options) + }) + } + + /** + * Stream one model call as completed content blocks — a convenience view + * for consumers that don't care about token-level deltas. + */ + async * streamBlocks(options: GenerateOptions): AsyncIterable { + const assembler = new BlockAssembler() + for await (const chunk of this.stream(options)) { + const block = assembler.push(chunk) + if (block) yield block + } + } + + /** One model call, fully assembled (drains the chunk stream). */ + generate(options: GenerateOptions): Promise { + return this.ctx.waterfall(this, 'llm/generate', options, async () => { + const assembler = new BlockAssembler() + for await (const chunk of this.stream(options)) assembler.push(chunk) + return assembler.result() + }) + } +} + +export default LlmService diff --git a/packages/llm/src/types.ts b/packages/llm/src/types.ts new file mode 100644 index 0000000000..2b16095e74 --- /dev/null +++ b/packages/llm/src/types.ts @@ -0,0 +1,178 @@ +/** + * Provider-neutral message and streaming vocabulary. + * + * This is the canonical language spoken by the agent loop, session logs, and + * every plugin. Adapters translate it to provider wire formats (DeepSeek V4 + * first); nothing outside an adapter should ever see a provider-specific + * shape. + * + * Extensibility: the unions in this file are derived from interfaces + * (`ContentBlockMap`, `MessageSourceMap`, `FinishReasonMap`) so that plugins + * can extend them via declaration merging: + * + * ```ts + * declare module '@deepseek-ai/dsh-llm' { + * interface ContentBlockMap { + * video: { type: 'video'; url: string } + * } + * } + * ``` + */ + +/** Cache hint attached to a content block (provider-interpreted). */ +export type CacheHint = 'ephemeral' + +/** Plain text visible to the end user. */ +export interface TextBlock { + type: 'text' + text: string + cache?: CacheHint +} + +/** Reasoning / thinking content, distinct from visible text. */ +export interface ReasoningBlock { + type: 'reasoning' + text: string +} + +/** A tool invocation requested by the model. */ +export interface ToolCallBlock { + type: 'tool-call' + /** Provider-issued call id; correlates with the matching tool result. */ + id: string + name: string + /** Raw JSON string as produced by the model. */ + arguments: string +} + +/** The result of a tool invocation, sent back to the model. */ +export interface ToolResultBlock { + type: 'tool-result' + toolCallId: string + content: ContentBlock[] + isError?: boolean + cache?: CacheHint +} + +/** An image, by URL or data URL. */ +export interface ImageBlock { + type: 'image' + url: string + mimeType?: string + cache?: CacheHint +} + +/** + * All known content block shapes, keyed by their `type` tag. + * Merge-extensible: plugins add new block types via declaration merging. + */ +export interface ContentBlockMap { + 'text': TextBlock + 'reasoning': ReasoningBlock + 'tool-call': ToolCallBlock + 'tool-result': ToolResultBlock + 'image': ImageBlock +} + +export type ContentBlockType = keyof ContentBlockMap +export type ContentBlock = ContentBlockMap[ContentBlockType] + +/** A single message in a conversation history. */ +export interface Message { + role: 'system' | 'user' | 'assistant' + content: ContentBlock[] +} + +/** + * Where a message (or injected content) came from. + * Merge-extensible sum type — plugins add their own `kind`s. + */ +export interface MessageSourceMap { + user: { kind: 'user' } + plugin: { kind: 'plugin'; plugin: string } + agent: { kind: 'agent'; agentId: string } +} + +export type MessageSource = MessageSourceMap[keyof MessageSourceMap] + +/** + * Why a model response stopped. + * Merge-extensible so adapters can surface provider-specific reasons. + */ +export interface FinishReasonMap { + 'stop': { kind: 'stop' } + 'tool-calls': { kind: 'tool-calls' } + 'max-tokens': { kind: 'max-tokens' } + 'aborted': { kind: 'aborted' } + 'error': { kind: 'error'; message: string; code?: string } +} + +export type FinishReason = FinishReasonMap[keyof FinishReasonMap] + +/** Token accounting for one model call (cache fields are optional). */ +export interface TokenUsage { + inputTokens: number + outputTokens: number + cacheReadTokens?: number + cacheWriteTokens?: number + reasoningTokens?: number +} + +/** + * Raw streaming protocol emitted by adapters. + * + * A streaming response interleaves several typed blocks (text, reasoning, + * multiple tool calls); `index` ties each delta to its block, and `block-end` + * carries the fully-assembled ContentBlock so consumers don't have to + * re-assemble deltas themselves (use {@link BlockAssembler} when they do). + * + * TODO(review): this protocol needs careful review before the first real + * adapter lands (DeepSeek V4 wire format, partial JSON arguments, interleaved + * reasoning signatures, …). + */ +export type StreamChunk = + | { type: 'block-start'; index: number; blockType: ContentBlockType } + | { type: 'text-delta'; index: number; text: string } + | { type: 'reasoning-delta'; index: number; text: string } + | { type: 'tool-call-delta'; index: number; id: string; name?: string; argumentsDelta: string } + | { type: 'block-end'; index: number; block: ContentBlock } + | { type: 'usage'; usage: TokenUsage } + | { type: 'finish'; reason: FinishReason } + +/** + * JSON-schema description of a tool, as sent to the model. + * + * Declared here (not in dsh-tools) because it is part of {@link GenerateOptions}; + * dsh-tools' ToolDefinition and dsh-system-prompt's PromptAssembly both import + * it from this package. + */ +export interface ToolSchema { + name: string + description: string + /** JSON Schema object for the arguments. */ + parameters: Record + strict?: boolean +} + +/** A single model request, fully assembled. */ +export interface GenerateOptions { + model: string + messages: Message[] + /** System prompt text (adapters map to the provider's system slot). */ + system?: string + /** Tool schemas (adapters map to the provider's `tools` field). */ + tools?: ToolSchema[] + /** Assistant prefix continuation (prefill). */ + prefill?: ContentBlock[] + temperature?: number + maxTokens?: number + stop?: string[] + signal?: AbortSignal +} + +/** Non-streaming result, assembled from the chunk stream. */ +export interface GenerateResult { + message: Message + usage?: TokenUsage + finish: FinishReason +} diff --git a/packages/llm/tests/assembler.spec.ts b/packages/llm/tests/assembler.spec.ts new file mode 100644 index 0000000000..bfba4a5c14 --- /dev/null +++ b/packages/llm/tests/assembler.spec.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import { BlockAssembler, type StreamChunk } from '@deepseek-ai/dsh-llm' + +describe('BlockAssembler', () => { + it('assembles interleaved text, reasoning, and tool-call deltas', () => { + const chunks: StreamChunk[] = [ + { type: 'block-start', index: 0, blockType: 'reasoning' }, + { type: 'reasoning-delta', index: 0, text: 'thinking…' }, + { type: 'block-end', index: 0, block: { type: 'reasoning', text: 'thinking…' } }, + { type: 'block-start', index: 1, blockType: 'text' }, + { type: 'text-delta', index: 1, text: 'Hello' }, + { type: 'text-delta', index: 1, text: ' world' }, + { type: 'block-start', index: 2, blockType: 'tool-call' }, + { type: 'tool-call-delta', index: 2, id: 'call-1', name: 'echo', argumentsDelta: '{"text":' }, + { type: 'tool-call-delta', index: 2, id: 'call-1', argumentsDelta: '"hi"}' }, + { type: 'usage', usage: { inputTokens: 10, outputTokens: 5 } }, + { type: 'finish', reason: { kind: 'tool-calls' } }, + ] + const assembler = new BlockAssembler() + for (const chunk of chunks) assembler.push(chunk) + + expect(assembler.blocks()).toEqual([ + { type: 'reasoning', text: 'thinking…' }, + { type: 'text', text: 'Hello world' }, + { type: 'tool-call', id: 'call-1', name: 'echo', arguments: '{"text":"hi"}' }, + ]) + expect(assembler.usage).toEqual({ inputTokens: 10, outputTokens: 5 }) + expect(assembler.finish).toEqual({ kind: 'tool-calls' }) + expect(assembler.message().role).toBe('assistant') + }) + + it('returns the completed block from push() on block-end', () => { + const assembler = new BlockAssembler() + expect(assembler.push({ type: 'block-start', index: 0, blockType: 'text' })).toBeUndefined() + expect(assembler.push({ type: 'text-delta', index: 0, text: 'hi' })).toBeUndefined() + const block = assembler.push({ type: 'block-end', index: 0, block: { type: 'text', text: 'hi' } }) + expect(block).toEqual({ type: 'text', text: 'hi' }) + }) + + it('tolerates deltas without explicit block-start/end', () => { + const assembler = new BlockAssembler() + assembler.push({ type: 'text-delta', index: 0, text: 'implicit' }) + expect(assembler.blocks()).toEqual([{ type: 'text', text: 'implicit' }]) + expect(assembler.finish).toEqual({ kind: 'stop' }) + }) +}) diff --git a/packages/llm/tests/service.spec.ts b/packages/llm/tests/service.spec.ts new file mode 100644 index 0000000000..446c3f0136 --- /dev/null +++ b/packages/llm/tests/service.spec.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import LlmService, { GenerateOptions, LlmAdapter, StreamChunk } from '@deepseek-ai/dsh-llm' + +class ScriptedAdapter extends LlmAdapter { + constructor(private script: StreamChunk[]) { + super() + } + + async * stream(_options: GenerateOptions): AsyncIterable { + yield * this.script + } +} + +const SCRIPT: StreamChunk[] = [ + { type: 'block-start', index: 0, blockType: 'text' }, + { type: 'text-delta', index: 0, text: 'hi' }, + { type: 'finish', reason: { kind: 'stop' } }, +] + +describe('LlmService', () => { + it('routes stream() to the registered adapter and generate() assembles it', async () => { + const ctx = new Context() + await ctx.plugin(LlmService) + ctx.llm.registerAdapter(['test-model'], new ScriptedAdapter(SCRIPT)) + + const chunks: StreamChunk[] = [] + for await (const chunk of ctx.llm.stream({ model: 'test-model', messages: [] })) chunks.push(chunk) + expect(chunks).toHaveLength(3) + + const result = await ctx.llm.generate({ model: 'test-model', messages: [] }) + expect(result.message.content).toEqual([{ type: 'text', text: 'hi' }]) + expect(result.finish).toEqual({ kind: 'stop' }) + }) + + it('throws NO_ADAPTER for unregistered models', async () => { + const ctx = new Context() + await ctx.plugin(LlmService) + await expect(ctx.llm.generate({ model: 'nope', messages: [] })).rejects.toThrow('no adapter registered') + }) + + it('unregisters adapters when the owning fiber is disposed (HMR safety)', async () => { + const ctx = new Context() + await ctx.plugin(LlmService) + + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + inner.llm.registerAdapter(['scoped-model'], new ScriptedAdapter(SCRIPT)) + }, { inject: ['llm'] })) + expect(ctx.llm.models()).toEqual(['scoped-model']) + + await fiber.dispose() + expect(ctx.llm.models()).toEqual([]) + }) + + it('lets llm/stream waterfall listeners wrap the underlying stream', async () => { + const ctx = new Context() + await ctx.plugin(LlmService) + ctx.llm.registerAdapter(['test-model'], new ScriptedAdapter(SCRIPT)) + + ctx.on('llm/stream', function (options, next) { + const inner = next() + return (async function * () { + yield { type: 'block-start', index: 99, blockType: 'text' } satisfies StreamChunk + yield * inner + })() + }) + + const chunks: StreamChunk[] = [] + for await (const chunk of ctx.llm.stream({ model: 'test-model', messages: [] })) chunks.push(chunk) + expect(chunks).toHaveLength(4) + expect(chunks[0]).toMatchObject({ index: 99 }) + }) +}) diff --git a/packages/llm/tsconfig.json b/packages/llm/tsconfig.json new file mode 100644 index 0000000000..2617271c44 --- /dev/null +++ b/packages/llm/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": ["src"], + "references": [ + { "path": "../../vendor/cosmokit" }, + { "path": "../../vendor/cordis" } + ] +} diff --git a/packages/session/package.json b/packages/session/package.json new file mode 100644 index 0000000000..9773a9b48d --- /dev/null +++ b/packages/session/package.json @@ -0,0 +1,30 @@ +{ + "name": "@deepseek-ai/dsh-session", + "description": "Event-sourced session store 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" + } +} diff --git a/packages/session/src/index.ts b/packages/session/src/index.ts new file mode 100644 index 0000000000..d5c6b2fba2 --- /dev/null +++ b/packages/session/src/index.ts @@ -0,0 +1,163 @@ +import { Context, Service } from 'cordis' +import type { ContentBlock, Message, MessageSource } from '@deepseek-ai/dsh-llm' +import type { SessionEvent, SessionEventMap, SessionEventType } from './types.ts' + +export * from './types.ts' + +declare module 'cordis' { + interface Context { + sessions: SessionStore + } + + interface Events { + /** A session was created in the store. */ + 'session/created'(session: Session): void + /** An event was appended to a session log (sync, fire-and-forget). */ + 'session/event'(session: Session, event: SessionEvent): void + /** + * Awaited durability checkpoint. The agent loop awaits + * `ctx.parallel('session/flush', session)` at every turn end; persistence + * plugins (JSONL, sqlite — TODO, future phase) drain their write-behind + * buffers here and on fiber dispose. + */ + 'session/flush'(session: Session): Promise | void + } +} + +/** + * Renders a `context/message` or `steering/message` event as a tagged + * synthetic user-role message (the system-reminder pattern: zero adapter + * burden, models distinguish it from real user prompts by the envelope). + * + * TODO(review): revisit the envelope once a real adapter exists. + */ +function renderTagged(tag: string, content: ContentBlock[], source: MessageSource): ContentBlock[] { + const open = `<${tag} source=${JSON.stringify(source.kind)}>` + const close = `` + return [ + { type: 'text', text: open }, + ...content, + { type: 'text', text: close }, + ] +} + +/** + * An event-sourced session: an append-only log of {@link SessionEvent}s. + * + * Plain class (not a Service) — create instances via `ctx.sessions.create()`. + * Seeding with an existing event log replays/forks a session. + */ +export class Session { + private log: SessionEvent[] = [] + /** Set by the store so appends are observable; no-op when detached. */ + onAppend?: (event: SessionEvent) => void + + constructor(public readonly id: string, seed?: SessionEvent[]) { + if (seed) this.log = [...seed] + } + + get events(): readonly SessionEvent[] { + return this.log + } + + get seq(): number { + return this.log.length + } + + /** Append one event. Synchronous — the hot path never blocks on I/O. */ + append(type: T, data: SessionEventMap[T]): SessionEvent { + const event: SessionEvent = { type, seq: this.log.length, time: Date.now(), data } + this.log.push(event as SessionEvent) + this.onAppend?.(event as SessionEvent) + return event + } + + /** + * Derive the LLM message history from the event log. + * + * - `user/message` → user message + * - `assistant/message` → assistant message (chunks are skipped — they are + * replay/UI data; the assembled message is authoritative for history) + * - `tool/result` → user message carrying a tool-result block + * - `context/message` / `steering/message` → tagged synthetic user messages + * at their chronological position + */ + deriveMessages(): Message[] { + const messages: Message[] = [] + for (const event of this.log) { + switch (event.type) { + case 'user/message': { + const { content } = event.data as SessionEventMap['user/message'] + messages.push({ role: 'user', content }) + break + } + case 'assistant/message': { + const { content } = event.data as SessionEventMap['assistant/message'] + messages.push({ role: 'assistant', content }) + break + } + case 'tool/result': { + const { callId, content, isError } = event.data as SessionEventMap['tool/result'] + messages.push({ + role: 'user', + content: [{ type: 'tool-result', toolCallId: callId, content, isError }], + }) + break + } + case 'context/message': { + const { content, source } = event.data as SessionEventMap['context/message'] + messages.push({ role: 'user', content: renderTagged('context', content, source) }) + break + } + case 'steering/message': { + const { content, source } = event.data as SessionEventMap['steering/message'] + messages.push({ role: 'user', content: renderTagged('steering', content, source) }) + break + } + } + } + return messages + } +} + +/** + * In-memory session store (`ctx.sessions`). + * + * Persistence is intentionally not implemented here — persistence plugins + * subscribe to `session/event` and flush on `session/flush` / dispose. + */ +export class SessionStore extends Service { + private store = new Map() + private counter = 0 + + constructor(ctx: Context) { + super(ctx, 'sessions') + } + + /** Create a session. `seed` replays/forks an existing event log. */ + create(id?: string, seed?: SessionEvent[]): Session { + id ??= `session-${++this.counter}` + if (this.store.has(id)) throw new Error(`session "${id}" already exists`) + const session = new Session(id, seed) + session.onAppend = (event) => this.ctx.emit('session/event', session, event) + this.ctx.effect(() => { + this.store.set(id, session) + this.ctx.emit('session/created', session) + return () => { + session.onAppend = undefined + this.store.delete(id) + } + }, 'sessions.create()') + return session + } + + get(id: string): Session | undefined { + return this.store.get(id) + } + + list(): Session[] { + return [...this.store.values()] + } +} + +export default SessionStore diff --git a/packages/session/src/types.ts b/packages/session/src/types.ts new file mode 100644 index 0000000000..c108019f07 --- /dev/null +++ b/packages/session/src/types.ts @@ -0,0 +1,74 @@ +import type { ContentBlock, MessageSource, StreamChunk, TokenUsage } from '@deepseek-ai/dsh-llm' + +/** + * What started a turn. + * Merge-extensible sum type (same pattern as MessageSourceMap). + */ +export interface TurnTriggerMap { + message: { kind: 'message'; source: MessageSource } + continuation: { kind: 'continuation' } +} + +export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap] + +/** + * Why a turn ended. + * Merge-extensible sum type. + */ +export interface TurnEndReasonMap { + completed: { kind: 'completed' } + aborted: { kind: 'aborted'; reason?: string } + error: { kind: 'error'; message: string; code?: string } + disposed: { kind: 'disposed' } +} + +export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap] + +/** + * The session event vocabulary — the append-only source of truth for an + * agent's whole interaction history. The LLM message history is *derived* + * from this log; nothing else is authoritative. Replay = re-derive from the + * same events; trace/telemetry = subscribe to the log. + * + * Merge-extensible: plugins declare extra event types via declaration merging + * (e.g. a compaction plugin adds `'compaction/marker'`). + * + * TODO(review): this vocabulary needs careful review once the loop and the + * first persistence plugin exist side by side. + */ +export interface SessionEventMap { + 'turn/start': { turn: number; trigger: TurnTrigger } + 'turn/end': { turn: number; reason: TurnEndReason } + 'step/start': { turn: number; step: number } + 'step/end': { turn: number; step: number } + /** A user-visible prompt (queued message drained at turn start). */ + 'user/message': { content: ContentBlock[]; source: MessageSource } + /** + * In-session context injection (file-change notices, subdir AGENTS.md, + * skill content, cron notifications, …). Rendered into the derived history + * as tagged synthetic context — NOT a user prompt. + */ + 'context/message': { content: ContentBlock[]; source: MessageSource } + /** Raw stream chunk — token-level replay fidelity. */ + 'assistant/chunk': { turn: number; step: number; chunk: StreamChunk } + /** Assembled assistant message for one step (derived history uses this). */ + 'assistant/message': { turn: number; step: number; content: ContentBlock[] } + 'tool/call': { turn: number; step: number; callId: string; name: string; arguments: string } + 'tool/result': { turn: number; step: number; callId: string; content: ContentBlock[]; isError: boolean } + /** Steering content injected between steps of a running turn. */ + 'steering/message': { turn: number; content: ContentBlock[]; source: MessageSource } + 'usage': { turn: number; step: number; usage: TokenUsage } + 'error': { turn: number; step: number; message: string; code?: string } +} + +export type SessionEventType = keyof SessionEventMap + +/** One immutable entry in the session log. */ +export interface SessionEvent { + type: T + /** Monotonic sequence number within the session. */ + seq: number + /** Unix epoch milliseconds. */ + time: number + data: SessionEventMap[T] +} diff --git a/packages/session/tests/session.spec.ts b/packages/session/tests/session.spec.ts new file mode 100644 index 0000000000..e5945604db --- /dev/null +++ b/packages/session/tests/session.spec.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import SessionStore, { Session, SessionEvent } from '@deepseek-ai/dsh-session' + +describe('Session', () => { + it('derives message history from the event log', () => { + const session = new Session('s1') + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('user/message', { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } }) + session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'hi' } }) + session.append('assistant/message', { + turn: 1, step: 1, + content: [ + { type: 'text', text: 'let me check' }, + { type: 'tool-call', id: 'c1', name: 'echo', arguments: '{}' }, + ], + }) + session.append('tool/result', { turn: 1, step: 1, callId: 'c1', content: [{ type: 'text', text: 'ok' }], isError: false }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + + const messages = session.deriveMessages() + expect(messages.map(m => m.role)).toEqual(['user', 'assistant', 'user']) + // raw chunks must NOT appear in derived history + expect(messages[1].content).toHaveLength(2) + expect(messages[2].content[0]).toMatchObject({ type: 'tool-result', toolCallId: 'c1' }) + }) + + it('renders context and steering messages as tagged synthetic user content', () => { + const session = new Session('s2') + session.append('context/message', { + content: [{ type: 'text', text: 'file changed: a.ts' }], + source: { kind: 'plugin', plugin: 'watcher' }, + }) + session.append('steering/message', { + turn: 1, + content: [{ type: 'text', text: 'focus on tests' }], + source: { kind: 'user' }, + }) + + const [contextMessage, steeringMessage] = session.deriveMessages() + expect(contextMessage.role).toBe('user') + expect(contextMessage.content[0]).toMatchObject({ type: 'text', text: '' }) + expect(contextMessage.content.at(-1)).toMatchObject({ type: 'text', text: '' }) + expect(steeringMessage.content[0]).toMatchObject({ type: 'text', text: '' }) + }) + + it('replays identically from a seeded event log', () => { + const original = new Session('s3') + original.append('user/message', { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }) + original.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'a' }] }) + + const replayed = new Session('s3-replay', [...original.events]) + expect(replayed.deriveMessages()).toEqual(original.deriveMessages()) + expect(replayed.seq).toBe(original.seq) + }) +}) + +describe('SessionStore', () => { + it('creates sessions, emits session/created and session/event', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + + const created: Session[] = [] + const events: [Session, SessionEvent][] = [] + ctx.on('session/created', session => void created.push(session)) + ctx.on('session/event', (session, event) => void events.push([session, event])) + + const session = ctx.sessions.create() + expect(created).toEqual([session]) + + session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } }) + expect(events).toHaveLength(1) + expect(events[0][0]).toBe(session) + expect(events[0][1].type).toBe('user/message') + + expect(ctx.sessions.get(session.id)).toBe(session) + expect(ctx.sessions.list()).toEqual([session]) + }) + + it('rejects duplicate ids and supports seeding', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const a = ctx.sessions.create('fixed') + expect(() => ctx.sessions.create('fixed')).toThrow('already exists') + + a.append('user/message', { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }) + const forked = ctx.sessions.create('fork', [...a.events]) + expect(forked.deriveMessages()).toEqual(a.deriveMessages()) + }) + + it('detaches sessions when the creating fiber is disposed (HMR safety)', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + + let session!: Session + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + session = inner.sessions.create('scoped') + }, { inject: ['sessions'] })) + expect(ctx.sessions.get('scoped')).toBe(session) + + let observed = 0 + ctx.on('session/event', () => void observed++) + + await fiber.dispose() + expect(ctx.sessions.get('scoped')).toBeUndefined() + session.append('user/message', { content: [{ type: 'text', text: 'late' }], source: { kind: 'user' } }) + expect(observed).toBe(0) + }) +}) diff --git a/packages/session/tsconfig.json b/packages/session/tsconfig.json new file mode 100644 index 0000000000..e226412a53 --- /dev/null +++ b/packages/session/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": ["src"], + "references": [ + { "path": "../../vendor/cosmokit" }, + { "path": "../../vendor/cordis" }, + { "path": "../llm" } + ] +} diff --git a/packages/system-prompt/package.json b/packages/system-prompt/package.json new file mode 100644 index 0000000000..e2be58448e --- /dev/null +++ b/packages/system-prompt/package.json @@ -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" + } +} diff --git a/packages/system-prompt/src/index.ts b/packages/system-prompt/src/index.ts new file mode 100644 index 0000000000..7069ef5b18 --- /dev/null +++ b/packages/system-prompt/src/index.ts @@ -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): Promise + /** 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 { + 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 diff --git a/packages/system-prompt/tests/system-prompt.spec.ts b/packages/system-prompt/tests/system-prompt.spec.ts new file mode 100644 index 0000000000..3e4b1a5f77 --- /dev/null +++ b/packages/system-prompt/tests/system-prompt.spec.ts @@ -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) + }) +}) diff --git a/packages/system-prompt/tsconfig.json b/packages/system-prompt/tsconfig.json new file mode 100644 index 0000000000..e226412a53 --- /dev/null +++ b/packages/system-prompt/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": ["src"], + "references": [ + { "path": "../../vendor/cosmokit" }, + { "path": "../../vendor/cordis" }, + { "path": "../llm" } + ] +} diff --git a/packages/tools/package.json b/packages/tools/package.json new file mode 100644 index 0000000000..143ffdbd6e --- /dev/null +++ b/packages/tools/package.json @@ -0,0 +1,34 @@ +{ + "name": "@deepseek-ai/dsh-tools", + "description": "Tool registry and execution pipeline 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-agent": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-system-prompt": "^0.0.1", + "cordis": "^4.0.0-rc.6" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-system-prompt": "^0.0.1", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/tools/src/index.ts b/packages/tools/src/index.ts new file mode 100644 index 0000000000..b93f20c354 --- /dev/null +++ b/packages/tools/src/index.ts @@ -0,0 +1,117 @@ +import { Context, Service } from 'cordis' +import type { ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type {} from '@deepseek-ai/dsh-system-prompt' + +declare module 'cordis' { + interface Context { + tools: ToolRegistry + } + + interface Events { + /** + * Waterfall around every tool execution — the single seam where sandbox, + * permission, hook, and plan-mode plugins wrap or veto a call. Listeners + * receive `(exec, next)`: call `next()` to proceed (possibly around your + * own logic), or return a ToolExecutionResult without calling `next()` + * to short-circuit (veto). + */ + 'tools/execute'(this: ToolRegistry, exec: ToolExecution, next: () => Promise): Promise + /** A tool was registered or unregistered. */ + 'tools/change'(): void + } +} + +// TODO(review): revisit these shapes when the first real tools and +// sandbox/permission plugins land (e.g. a concurrency-safety hint for +// parallel execution — Claude Code partitions read-only tools; phase 1 +// executes sequentially). + +/** A registered tool: its schema plus the execution function. */ +export interface ToolDefinition extends ToolSchema { + execute(args: unknown, exec: ToolExecution): Promise +} + +/** One pending tool call, as it flows through the execution waterfall. */ +export interface ToolExecution { + callId: string + name: string + /** Parsed JSON arguments (unknown — tools validate their own input). */ + arguments: unknown + /** The agent on whose behalf the call runs (set by the agent loop). */ + agent?: Agent + signal?: AbortSignal +} + +/** The outcome of one tool call. */ +export interface ToolExecutionResult { + callId: string + content: ContentBlock[] + isError: boolean +} + +/** + * Tool registry (`ctx.tools`): tool plugins register definitions; the agent + * loop executes calls through the `tools/execute` waterfall. The registry + * contributes its schemas into the system-prompt assembly. + */ +export class ToolRegistry extends Service { + static inject = ['systemPrompt'] + + private store = new Map() + + constructor(ctx: Context) { + super(ctx, 'tools') + ctx.systemPrompt.tools(() => this.schemas()) + } + + /** Register a tool. Disposed with the calling fiber. */ + register(definition: ToolDefinition): () => void { + return this.ctx.effect(() => { + if (this.store.has(definition.name)) { + throw new Error(`tool "${definition.name}" is already registered`) + } + this.store.set(definition.name, definition) + this.ctx.emit('tools/change') + return () => { + this.store.delete(definition.name) + this.ctx.emit('tools/change') + } + }, 'tools.register()') + } + + get(name: string): ToolDefinition | undefined { + return this.store.get(name) + } + + /** Schemas of all registered tools (without the execute functions). */ + schemas(): ToolSchema[] { + return [...this.store.values()].map(({ execute, ...schema }) => schema) + } + + /** Execute one tool call through the `tools/execute` waterfall. */ + execute(exec: ToolExecution): Promise { + return this.ctx.waterfall(this, 'tools/execute', exec, async (): Promise => { + const tool = this.store.get(exec.name) + if (!tool) { + return { + callId: exec.callId, + content: [{ type: 'text', text: `Error: unknown tool "${exec.name}"` }], + isError: true, + } + } + try { + const content = await tool.execute(exec.arguments, exec) + return { callId: exec.callId, content, isError: false } + } catch (error: any) { + return { + callId: exec.callId, + content: [{ type: 'text', text: `Error: ${error?.message ?? error}` }], + isError: true, + } + } + }) + } +} + +export default ToolRegistry diff --git a/packages/tools/tests/tools.spec.ts b/packages/tools/tests/tools.spec.ts new file mode 100644 index 0000000000..e06b1795de --- /dev/null +++ b/packages/tools/tests/tools.spec.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry, { ToolExecutionResult } from '@deepseek-ai/dsh-tools' + +async function setup() { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + return ctx +} + +const echoTool = { + name: 'echo', + description: 'echo arguments back', + parameters: { type: 'object', properties: { text: { type: 'string' } } }, + async execute(args: any) { + return [{ type: 'text' as const, text: String(args?.text ?? '') }] + }, +} + +describe('ToolRegistry', () => { + it('registers tools, exposes schemas, and feeds the system-prompt assembly', async () => { + const ctx = await setup() + ctx.tools.register(echoTool) + + expect(ctx.tools.schemas()).toEqual([{ + name: 'echo', + description: 'echo arguments back', + parameters: { type: 'object', properties: { text: { type: 'string' } } }, + }]) + // schemas() result must not leak execute + expect((ctx.tools.schemas()[0] as any).execute).toBeUndefined() + + const assembly = await ctx.systemPrompt.assemble() + expect(assembly.tools.map(t => t.name)).toEqual(['echo']) + }) + + it('executes a tool and returns its content', async () => { + const ctx = await setup() + ctx.tools.register(echoTool) + const result = await ctx.tools.execute({ callId: 'c1', name: 'echo', arguments: { text: 'hi' } }) + expect(result).toEqual({ callId: 'c1', content: [{ type: 'text', text: 'hi' }], isError: false }) + }) + + it('returns isError results for unknown tools and throwing tools', async () => { + const ctx = await setup() + ctx.tools.register({ + ...echoTool, + name: 'boom', + async execute() { + throw new Error('exploded') + }, + }) + + const unknown = await ctx.tools.execute({ callId: 'c1', name: 'nope', arguments: {} }) + expect(unknown.isError).toBe(true) + + const thrown = await ctx.tools.execute({ callId: 'c2', name: 'boom', arguments: {} }) + expect(thrown.isError).toBe(true) + expect(thrown.content[0]).toMatchObject({ text: 'Error: exploded' }) + }) + + it('lets tools/execute waterfall listeners veto a call (permission pattern)', async () => { + const ctx = await setup() + ctx.tools.register(echoTool) + + ctx.on('tools/execute', async (exec, next): Promise => { + if (exec.name === 'echo') { + return { + callId: exec.callId, + content: [{ type: 'text', text: 'denied by policy' }], + isError: true, + } + } + return next() + }) + + const result = await ctx.tools.execute({ callId: 'c1', name: 'echo', arguments: { text: 'hi' } }) + expect(result.isError).toBe(true) + expect(result.content[0]).toMatchObject({ text: 'denied by policy' }) + }) + + it('composes multiple tools/execute listeners (sandbox-wrap pattern)', async () => { + const ctx = await setup() + ctx.tools.register(echoTool) + + const order: string[] = [] + ctx.on('tools/execute', async (exec, next) => { + order.push('first:before') + const result = await next() + order.push('first:after') + return result + }) + ctx.on('tools/execute', async (exec, next) => { + order.push('second:before') + const result = await next() + order.push('second:after') + return result + }) + + const result = await ctx.tools.execute({ callId: 'c1', name: 'echo', arguments: { text: 'x' } }) + expect(result.isError).toBe(false) + expect(order).toEqual(['first:before', 'second:before', 'second:after', 'first:after']) + }) + + it('rejects duplicate names and unregisters on fiber dispose (HMR safety)', async () => { + const ctx = await setup() + ctx.tools.register(echoTool) + expect(() => ctx.tools.register(echoTool)).toThrow('already registered') + + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + inner.tools.register({ ...echoTool, name: 'scoped' }) + }, { inject: ['tools'] })) + expect(ctx.tools.schemas().map(t => t.name)).toEqual(['echo', 'scoped']) + + await fiber.dispose() + expect(ctx.tools.schemas().map(t => t.name)).toEqual(['echo']) + }) +}) diff --git a/packages/tools/tsconfig.json b/packages/tools/tsconfig.json new file mode 100644 index 0000000000..8e29228fc8 --- /dev/null +++ b/packages/tools/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": ["src"], + "references": [ + { "path": "../../vendor/cosmokit" }, + { "path": "../../vendor/cordis" }, + { "path": "../llm" }, + { "path": "../system-prompt" }, + { "path": "../agent" } + ] +}