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:
Tianyi Cui
2026-06-11 10:54:06 +08:00
parent 72688a3888
commit d5a1d9bb75
25 changed files with 1703 additions and 0 deletions

View File

@@ -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"
}
}

View File

@@ -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<string, Agent>()
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

107
packages/agent/src/types.ts Normal file
View File

@@ -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<GenerateOptions>): Promise<GenerateOptions>
/**
* 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<Message>): Promise<Message>
/**
* 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<boolean>): Promise<boolean>
// ---- 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
}
}

View File

@@ -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'])
})
})

View File

@@ -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" }
]
}