Document the codebase thoroughly and tighten type safety

Docs: per-folder README.md for packages/ (family overview + one per
package: service, events, API, extension points, TODOs), examples/,
and examples/echo-agent/; folder-level AGENTS.md (+ CLAUDE.md
symlinks) for packages/ and vendor/; module-level doc comments in
every packages/*/src file; richer JSDoc on all exported API
(event side effects, disposal contracts, error behavior). Root
AGENTS.md gains a "Type Safety and Documentation" policy section:
the codebase aims to be very type-safe and well documented; type
gymnastics are acceptable in core packages when they improve
plugin-author DX; verbose docs are fine as long as they stay strictly
in sync with the code.

Type safety: removed the upstream-inherited "noImplicitAny": false
from tsconfig.base.json — packages/* now compile under full strict
mode; vendor/loader and vendor/include set it locally (vendor/cordis
already did). Eliminated every `: any` / `as any` from packages and
examples (catch clauses use unknown + a CodedError narrowing type;
event data access uses discriminated-union narrowing).

Typed tool schemas: new @deepseek-ai/dsh-tools schema DSL —
SchemaSpec with per-property `required: true` booleans, type-level
InferArgs<S>, a runtime SchemaSpec → JSON Schema converter, and
defineTool() so first-party tools get typed execute(args) with zero
casts (raw JSON Schema still accepted for MCP interop; chosen over
schemastery because it targets JSON Schema generation directly).
echo-tool and all test tools migrated; +7 tests.
This commit is contained in:
Tianyi Cui
2026-06-11 12:39:27 +08:00
parent 217b8ec0e2
commit 7f024a1a9d
35 changed files with 1274 additions and 101 deletions

View File

@@ -78,6 +78,30 @@ only needed for publishing/consumption outside the repo.
concurrency races even if they seem unlikely. Review findings get regression
tests (see `packages/agent-loop/tests/review-fixes.spec.ts`).
## Type Safety and Documentation
This codebase aims to be **very type-safe and well documented** for
maintainability. Code that fails to compile under `strict: true` (with
`noImplicitAny` enabled for all `packages/*` source) is not acceptable. Every
`any` that remains must have a specific justification (a comment explaining why
a narrower type is infeasible).
In the **core** packages (`packages/llm`, `packages/tools`, `packages/agent`,
`packages/agent-loop`, `packages/session`, `packages/system-prompt`), **type
gymnastics are acceptable when they improve the DX of plugin authors** for
common plugin types. The `defineTool` typed schema DSL in `dsh-tools` is the
canonical example: the `SchemaSpec` to `InferArgs<S>` type-level mapping gives
tool authors zero-cast typed `execute` args, and the cost of the conditional
types stays inside the core package.
Verbose documentation is fine **as long as docs and code stay strictly in
sync**. Out-of-sync docs are worse than no docs. Every module has a module-level
doc comment explaining its role. Every exported class, interface, type,
function, and non-obvious method has a JSDoc that explains semantics (not just
the name) — contracts (what events fire when), disposal behavior, error
behavior, and extension intent. Internal helpers get docs only where non-obvious.
Prefer one-liners when one line suffices.
## Vendoring Policy
`vendor/` packages are pinned source copies (manifest with upstream commit

View File

@@ -16,4 +16,4 @@ yarn test # vitest
yarn demo # runnable echo-agent example
```
See [AGENTS.md](AGENTS.md) for layout, commands, and conventions, and [docs/architecture.md](docs/architecture.md) for the design.
For agent instructions see [AGENTS.md](AGENTS.md). For the architecture design see [docs/architecture.md](docs/architecture.md). Each subdirectory has its own README.md with local context: [packages/](packages/), [vendor/](vendor/).

17
examples/README.md Normal file
View File

@@ -0,0 +1,17 @@
# Examples
Runnable demos (not workspaces) that showcase how the harness is wired.
## echo-agent
A mock model + echo tool + stdio UI + JSONL persistence demo. Demonstrates:
- Loading plugins from a `cordis.yml` via `@cordisjs/plugin-loader` + `@cordisjs/plugin-include`
- Registering a mock `LlmAdapter` (streaming scripted responses)
- Registering a tool via `ctx.tools.register()`
- Persisting session events to JSONL via the `session/event` + `session/flush` pattern
- A minimal stdio UI consuming `agent/stream-chunk` and session events
Run with: `yarn demo` (or `node --expose-internals --import tsx examples/echo-agent/start.ts`)
When prompted, type "echo <something>" to trigger a tool call round-trip.

View File

@@ -0,0 +1,40 @@
# echo-agent
Runnable demo: stdin chat with a scripted mock model and an echo tool.
## What it shows
- A complete Cordis app loaded from `cordis.yml` — the standard "stack of plugins" pattern
- `mock-llm.ts` — a mock `LlmAdapter` that streams scripted responses and calls the
`echo` tool when the user types "echo <something>"
- `echo-tool.ts` — a tool registered via `ctx.tools.register()` that echoes text
back uppercased
- `session-jsonl.ts` — a minimal persistence plugin: write-behind buffering of
`session/event` notifications, drained to a JSONL file at `session/flush`
- `stdio-chat.ts` — a minimal UI plugin: reads stdin lines and `send`/`steer`s
the agent, renders stream deltas, tool calls, and tool results
## Plugin files
| File | Role | Key patterns demonstrated |
|---|---|---|
| `mock-llm.ts` | `LlmAdapter` registration | `ctx.llm.registerAdapter([])`, streaming chunks with proper `block-start`/`block-end` protocol |
| `echo-tool.ts` | Tool registration | `ctx.tools.register()`, tool execution returning `ContentBlock[]` |
| `session-jsonl.ts` | Persistence | `session/event` listener + `session/flush` drain, fiber-dispose cleanup |
| `stdio-chat.ts` | UI | `agent/stream-chunk`, `session/event` (tool/*), stdin→send/steer |
| `start.ts` | Bootstrap | `Context` + `Loader` + `plugin-include` wired to `cordis.yml` |
## Run
```sh
yarn demo
# or:
node --expose-internals --import tsx examples/echo-agent/start.ts
```
Type a message and press Enter. "echo <text>" triggers a tool call round-trip
(the mock model requests the `echo` tool, which echoes the text uppercased,
and the next model step acknowledges it).
The session is persisted to `<session-id>.jsonl` in the `examples/echo-agent/`
directory. Clean up with: `rm -f examples/echo-agent/*.jsonl`

View File

@@ -1,19 +1,19 @@
import type { Context } from 'cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
export const name = 'echo-tool'
export const inject = ['tools']
export function apply(ctx: Context) {
ctx.tools.register({
ctx.tools.register(defineTool({
name: 'echo',
description: 'Echo the given text back, uppercased.',
parameters: {
type: 'object',
properties: { text: { type: 'string' } },
required: ['text'],
text: { type: 'string', required: true },
},
async execute(args: any) {
return [{ type: 'text', text: `ECHO: ${String(args?.text ?? '').toUpperCase()}` }]
async execute(args) {
// args is typed: { text: string }
return [{ type: 'text', text: `ECHO: ${args.text.toUpperCase()}` }]
},
})
}))
}

View File

@@ -25,11 +25,11 @@ export function apply(ctx: Context) {
ctx.on('session/event', (_session, event) => {
if (event.type === 'tool/call') {
const { name: toolName, arguments: args } = event.data as any
const { name: toolName, arguments: args } = event.data
process.stdout.write(`\n [tool call] ${toolName}(${args})`)
} else if (event.type === 'tool/result') {
const { content } = event.data as any
const text = content.filter((b: any) => b.type === 'text').map((b: any) => b.text).join('')
const { content } = event.data
const text = content.filter(b => b.type === 'text').map(b => b.text).join('')
process.stdout.write(`\n [tool result] ${text}\n `)
}
})

27
packages/AGENTS.md Normal file
View File

@@ -0,0 +1,27 @@
# AGENTS.md — Harness Packages
This directory contains all `@deepseek-ai/dsh-*` harness packages. When editing
code here, follow these conventions:
- **Effect-based registrations**: every contribution (tool, section, adapter,
agent, event listener) goes through `ctx.effect()` / `ctx.on()`, and
`register()` methods return disposers. Never use bare arrays or manual cleanup.
- **Declaration merging**: services declare their ctx key in
`declare module 'cordis' { interface Context { } }` and their events in
`interface Events`. Merge-extensible maps (`ContentBlockMap`,
`MessageSourceMap`, `FinishReasonMap`, `TurnTriggerMap`, `TurnEndReasonMap`,
`SessionEventMap`) are how plugins add new variants.
- **Waterfall semantics**: `ctx.waterfall` listeners receive `(...args, next)`;
call `next()` to delegate, or return without it to short-circuit (veto). Never
call `next()` after returning.
- **Tests**: vitest in `packages/<name>/tests/*.spec.ts`. Every registry needs an
HMR-safety test (register a plugin, dispose its fiber, assert cleanup). Err on
the side of more tests — edge cases, error paths, event ordering, races.
Naming notes:
- Files `src/index.ts` export the service default + all public types
- `src/types.ts` contain only types — no runtime code
- Tests live at package level under `tests/`, not `src/__tests__/`
Read the per-package README.md for package-specific details: service API,
events, extension points, TODOs.

1
packages/CLAUDE.md Symbolic link
View File

@@ -0,0 +1 @@
AGENTS.md

56
packages/README.md Normal file
View File

@@ -0,0 +1,56 @@
# Packages
Harness packages, all under the `@deepseek-ai/dsh-*` scope. Each package is a
Cordis service (microkernel plugin-style): it exports a default `Service` class
that gets registered via `ctx.plugin()`, declares its ctx key and events through
declaration merging, and exposes extension points through `ctx.effect()`,
`ctx.on()`, and `ctx.waterfall()`.
## Dependency graph
```
dsh-llm (no harness deps — pure vocabulary)
dsh-session ← dsh-llm
dsh-system-prompt ← dsh-llm
dsh-agent ← dsh-llm, dsh-session
dsh-tools ← dsh-llm, dsh-system-prompt, dsh-agent
dsh-agent-loop ← dsh-llm, dsh-session, dsh-system-prompt, dsh-tools, dsh-agent
```
The rule: plugins depend on interfaces, never on the concrete loop.
`dsh-agent-loop` is swappable — UI/hook/tool plugins keep working against the
`dsh-agent` vocabulary if the loop is replaced.
## What goes where
| Package | Role | ctx key |
|---|---|---|
| `llm/` | Abstract LLM service + content-block vocabulary + chunk assembler | `ctx.llm` |
| `session/` | Event-sourced session log + in-memory store | `ctx.sessions` |
| `system-prompt/` | Prompt-section + tool-schema assembly registry | `ctx.systemPrompt` |
| `tools/` | Tool registry + `tools/execute` waterfall | `ctx.tools` |
| `agent/` | Agent interface, registry, `agent/*` event vocabulary | `ctx.agents` |
| `agent-loop/` | THE concrete plugin: `LoopAgent` + the loop driver | `ctx.agentLoop` |
Each package has its own `README.md` with purpose, service API, events,
extension points, and deliberate non-goals (TODOs).
## Conventions (applied across all harness packages)
- **Registrations are effects**: every contribution (adapter, tool, section,
agent, event listener) goes through `ctx.effect()` / `ctx.on()`, so disposal
and HMR clean up automatically. Every `register()` returns the disposer.
- **Declaration merging for events and ctx**: services declare their events in
`declare module 'cordis' { interface Events { ... } }` and their ctx key in
`interface Context`.
- **Waterfall semantics**: `ctx.waterfall` listeners receive `(...args, next)`
and MUST call `next()` to delegate; returning without it short-circuits (the
veto mechanism).
- **Extensible unions**: `ContentBlockMap`, `MessageSourceMap`,
`FinishReasonMap`, `TurnTriggerMap`, `TurnEndReasonMap`, and `SessionEventMap`
use the merge-extensible-map pattern so plugins can add variants via
declaration merging.
- **ESM everywhere**; imports use package names across package boundaries,
`.ts` extensions within a package.
- **Tests**: vitest, colocated under `packages/<name>/tests/*.spec.ts`. Every
registry needs an HMR-safety test. Err on the side of more tests.

View File

@@ -0,0 +1,83 @@
# dsh-agent-loop
THE concrete agent plugin: `LoopAgent` and the loop driver. Implements the
`Agent` interface and drives the session/turn/step lifecycle.
This is the only package in the harness that contains concrete loop logic.
Everything else is an abstract service or a plugin against extension seams —
new behavior goes into plugins, not here.
## Service: `AgentLoop` (ctx key: `agentLoop`)
### Public API
- `ctx.agentLoop.create(id: string, options?: AgentOptions): LoopAgent`
Create an agent, start its loop, and register it in `ctx.agents`. Disposed
with the calling fiber.
### Injected services
`agents`, `sessions`, `llm`, `tools`, `systemPrompt` — all five interface
services.
### Configuration (schemastery)
```ts
Config: {
agents: Array<{
id: string // required
model?: string
systemPrompt?: string
}>
}
```
Agents listed in config are auto-created at startup.
### Classes
- `LoopAgent` — the concrete `Agent` implementation. Owns the inbox (`Inbox`),
the per-step `AbortController`, and the loop driver. Everything observable
happens through session events and the `agent/*` event taxonomy.
- `Inbox` — per-agent queued + steering FIFOs (`enqueue`, `steer`, `drainQueued`,
`drainSteering`, `waitForQueued`).
### Loop lifecycle (`loop.ts`)
One invocation of `runLoop()` drives one agent for its whole lifetime:
```
forever:
wait for queued messages (idle)
TURN (error-contained):
drain queued → session('user/message') → 'turn/start'
STEP loop:
drain steering
assembly = systemPrompt.assemble()
request = waterfall agent/request
stream llm.stream(request) → session('assistant/chunk')
message = waterfall agent/step-result
session('assistant/message')
each tool-call: session('tool/call') → tools.execute() → session('tool/result')
drain steering → session('steering/message')
cont = waterfall agent/turn-continuation
if !cont: break
session('turn/end')
await session/flush
re-enqueue leftover steering as queued
idle unless more queued
```
Error containment: a throwing plugin ends the **turn**, never the loop. Dispose
mid-turn emits `agent/status('disposed')` and ends with reason `disposed`.
### What is NOT here
Everything that goes beyond "call the model, run the tools, repeat" belongs to
plugins listening on the event taxonomy:
- Hooks: `agent/request`, `agent/step-result`, `tools/execute`, `agent/turn-continuation`
- Compaction: `agent/request`
- Sandbox, permission, plan mode: `tools/execute`
- Sub-agents: TODO seam on `AgentLoop.create()`
- Persistence: `session/event` + `session/flush`
- UI: `agent/stream-chunk` + `agent/*` events

View File

@@ -1,3 +1,11 @@
/**
* The concrete Agent implementation: LoopAgent plus its inbox. Everything
* observable happens through session events and the agent/* event taxonomy —
* plugins never need this class.
*
* @module dsh-agent-loop/agent
*/
import type { Context } from 'cordis'
import type { AgentOptions, AgentStatus, SendOptions } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
@@ -72,7 +80,12 @@ export class LoopAgent implements Agent {
this.currentAbort?.abort(reason ?? 'aborted')
}
/** Start the driver loop. Returns a disposer that stops it. */
/**
* Start the driver loop. Returns a disposer: calling it sets status to
* `disposed`, emits `agent/status('disposed')`, resolves the disposed
* promise (unblocking the idle wait), and aborts the current request if
* any. The returned `agent.done` promise resolves once the loop exits.
*/
start(): () => void {
this.done = runLoop(this.ctx, this, {
setStatus: status => this.setStatus(status),

View File

@@ -1,3 +1,11 @@
/**
* Per-agent message inbox: queued and steering FIFOs. Purely an in-memory
* mechanism of the loop driver — the public surface is `Agent.send()` and
* `Agent.steer()`.
*
* @module dsh-agent-loop/inbox
*/
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
/** One message waiting in an agent's inbox. */

View File

@@ -1,3 +1,12 @@
/**
* THE concrete agent plugin: creates LoopAgents, runs their loops, and
* registers them in ctx.agents. Deliberately thin — every behavior beyond
* "call the model, run the tools, repeat" belongs to plugins on the event
* taxonomy.
*
* @module @deepseek-ai/dsh-agent-loop
*/
import { Context, Service } from 'cordis'
import z from 'schemastery'
import type { AgentOptions } from '@deepseek-ai/dsh-agent'

View File

@@ -1,3 +1,12 @@
/**
* The agent loop driver: one `runLoop()` invocation drives one agent for its
* whole lifetime. Error-contained at the turn level — a throwing plugin ends
* the turn, never kills the loop. See the JSDoc on `runLoop()` for the full
* lifecycle pseudo-code.
*
* @module dsh-agent-loop/loop
*/
import type { Context } from 'cordis'
import type { GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
import { BlockAssembler } from '@deepseek-ai/dsh-llm'
@@ -6,6 +15,14 @@ import { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import type {} from '@deepseek-ai/dsh-tools'
import type { LoopAgent } from './agent.ts'
/** An Error with an optional machine-readable code (e.g., from LlmError or a throwing plugin). */
type CodedError = Error & { code?: string }
/**
* Ambient handles the loop driver receives from the agent. Decouples the
* pure function `runLoop` from the mutable LoopAgent fields, making the
* loop testable without a real agent.
*/
export interface LoopHandle {
setStatus(status: 'idle' | 'running'): void
setAbort(controller: AbortController | undefined): void
@@ -58,12 +75,12 @@ export async function runLoop(ctx: Context, agent: LoopAgent, handle: LoopHandle
turn += 1
try {
await runTurn(ctx, agent, handle, turn)
} catch (error: any) {
} catch (error: unknown) {
// Backstop: a throwing emit listener (turn boundaries) or a broken
// finalizer must not kill the driver. Record what we can and move on.
try {
const err = error instanceof Error ? error : new Error(String(error))
session.append('error', { turn, step: 0, message: err.message, code: (err as any).code })
const err: CodedError = error instanceof Error ? error : new Error(String(error))
session.append('error', { turn, step: 0, message: err.message, code: err.code })
ctx.emit('agent/error', agent, turn, 0, err)
} catch { /* the error path itself is broken; nothing left to do */ }
}
@@ -110,7 +127,7 @@ async function runTurn(ctx: Context, agent: LoopAgent, handle: LoopHandle, turn:
let stepOutcome: { hadToolCalls: boolean } | { error: Error }
try {
stepOutcome = await runStep(ctx, agent, turn, step, abort.signal)
} catch (error: any) {
} catch (error: unknown) {
stepOutcome = { error: error instanceof Error ? error : new Error(String(error)) }
} finally {
handle.setAbort(undefined)
@@ -128,9 +145,10 @@ async function runTurn(ctx: Context, agent: LoopAgent, handle: LoopHandle, turn:
} else if (abort.signal.aborted) {
reason = { kind: 'aborted', reason: String(abort.signal.reason ?? 'aborted') }
} else {
session.append('error', { turn, step, message: error.message, code: (error as any).code })
const coded = error as CodedError
session.append('error', { turn, step, message: coded.message, code: coded.code })
ctx.emit('agent/error', agent, turn, step, error)
reason = { kind: 'error', message: error.message, code: (error as any).code }
reason = { kind: 'error', message: coded.message, code: coded.code }
}
break
}
@@ -148,12 +166,12 @@ async function runTurn(ctx: Context, agent: LoopAgent, handle: LoopHandle, turn:
'agent/turn-continuation', agent, turn, defaultDecision,
async () => defaultDecision,
)
} catch (error: any) {
} catch (error: unknown) {
// A broken continuation plugin ends the turn, not the loop.
const err = error instanceof Error ? error : new Error(String(error))
session.append('error', { turn, step, message: err.message, code: (err as any).code })
const err: CodedError = error instanceof Error ? error : new Error(String(error))
session.append('error', { turn, step, message: err.message, code: err.code })
ctx.emit('agent/error', agent, turn, step, err)
reason = { kind: 'error', message: err.message, code: (err as any).code }
reason = { kind: 'error', message: err.message, code: err.code }
break
}
@@ -175,9 +193,9 @@ async function runTurn(ctx: Context, agent: LoopAgent, handle: LoopHandle, turn:
// A failing persistence plugin is reported but doesn't kill the agent.
try {
await ctx.parallel('session/flush', session)
} catch (error: any) {
const err = error instanceof Error ? error : new Error(String(error))
session.append('error', { turn, step, message: err.message, code: (err as any).code })
} catch (error: unknown) {
const err: CodedError = error instanceof Error ? error : new Error(String(error))
session.append('error', { turn, step, message: err.message, code: err.code })
ctx.emit('agent/error', agent, turn, step, err)
}
}

View File

@@ -1,9 +1,9 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService, { StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionEventType } from '@deepseek-ai/dsh-session'
import LlmService, { StreamChunk, ToolResultBlock } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionEventType, TurnEndReason } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import AgentLoop, { LoopAgent } from '@deepseek-ai/dsh-agent-loop'
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
@@ -76,14 +76,14 @@ describe('agent loop', () => {
textResponse('done'),
])
const ctx = await harness(adapter)
ctx.tools.register({
ctx.tools.register(defineTool({
name: 'echo',
description: 'echo back',
parameters: { type: 'object' },
async execute(args: any) {
parameters: { text: { type: 'string' } },
async execute(args) {
return [{ type: 'text', text: `echo: ${args.text}` }]
},
})
}))
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
send(agent, 'use the tool')
@@ -99,7 +99,7 @@ describe('agent loop', () => {
expect(toolResultMessage).toBeDefined()
const block = toolResultMessage!.content.find(b => b.type === 'tool-result')!
expect(block).toMatchObject({ toolCallId: 'c1', isError: false })
expect((block as any).content).toEqual([{ type: 'text', text: 'echo: ping' }])
expect((block as ToolResultBlock).content).toEqual([{ type: 'text', text: 'echo: ping' }])
// session log records call + result
const types = agent.session.events.map(e => e.type)
@@ -111,14 +111,14 @@ describe('agent loop', () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
ctx.systemPrompt.section({ name: 'persona', order: 0, text: 'You are a test agent.' })
ctx.tools.register({
ctx.tools.register(defineTool({
name: 'noop',
description: 'does nothing',
parameters: { type: 'object' },
parameters: {},
async execute() {
return []
},
})
}))
const agent = ctx.agentLoop.create('a1', { model: 'mock', systemPrompt: 'Agent-specific suffix.' })
send(agent, 'hi')
@@ -146,9 +146,9 @@ describe('agent loop', () => {
expect(streamed).toHaveLength(7)
// replay: chunk events alone re-assemble to the recorded assistant message
const deltaText = chunkEvents
.map(e => (e.data as any).chunk)
.filter((c: StreamChunk) => c.type === 'text-delta')
.map((c: any) => c.text)
.flatMap(e => e.type === 'assistant/chunk' ? [e.data.chunk] : [])
.filter((c: StreamChunk): c is Extract<StreamChunk, { type: 'text-delta' }> => c.type === 'text-delta')
.map(c => c.text)
.join('')
expect(deltaText).toBe('abc')
})
@@ -161,16 +161,16 @@ describe('agent loop', () => {
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
ctx.tools.register({
ctx.tools.register(defineTool({
name: 'slow',
description: '',
parameters: { type: 'object' },
parameters: {},
async execute() {
// steer while the turn is running (during tool execution)
agent.steer([{ type: 'text', text: 'change of plans' }])
return [{ type: 'text', text: 'tool done' }]
},
})
}))
send(agent, 'start')
await waitForIdle(ctx, agent)
@@ -243,14 +243,14 @@ describe('agent loop', () => {
it('agent/turn-continuation can veto continuation despite tool calls (budget-guard pattern)', async () => {
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', { text: 'x' })])
const ctx = await harness(adapter)
ctx.tools.register({
ctx.tools.register(defineTool({
name: 'echo',
description: '',
parameters: { type: 'object' },
async execute(args: any) {
parameters: { text: { type: 'string' } },
async execute(args) {
return [{ type: 'text', text: String(args.text) }]
},
})
}))
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
ctx.on('agent/turn-continuation', async () => false as const)
@@ -284,7 +284,7 @@ describe('agent loop', () => {
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
const reasons: any[] = []
const reasons: TurnEndReason[] = []
ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason))
send(agent, 'go')
@@ -348,7 +348,7 @@ describe('agent loop', () => {
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
const errors: Error[] = []
const reasons: any[] = []
const reasons: TurnEndReason[] = []
ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error))
ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason))
@@ -389,14 +389,14 @@ describe('agent loop', () => {
textResponse('done'),
])
const ctx = await harness(adapter)
ctx.tools.register({
ctx.tools.register(defineTool({
name: 'echo',
description: '',
parameters: { type: 'object' },
async execute(args: any) {
parameters: { text: { type: 'string' } },
async execute(args) {
return [{ type: 'text', text: String(args.text) }]
},
})
}))
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
send(agent, 'run')
await waitForIdle(ctx, agent)

View File

@@ -1,9 +1,9 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService, { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import LlmService, { ContentBlock, MessageSource, StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import AgentLoop, { LoopAgent } from '@deepseek-ai/dsh-agent-loop'
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
@@ -45,15 +45,15 @@ describe('HIGH: session log records what agent/step-result actually produced', (
const adapter = new MockAdapter([textResponse('original'), textResponse('done')])
const ctx = await harness(adapter)
const executed: string[] = []
ctx.tools.register({
ctx.tools.register(defineTool({
name: 'injected-tool',
description: '',
parameters: { type: 'object' },
parameters: {},
async execute() {
executed.push('injected-tool')
return [{ type: 'text', text: 'ran' }]
},
})
}))
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
// Plugin rewrites the message: replaces the text AND adds a tool call.
@@ -81,7 +81,8 @@ describe('HIGH: session log records what agent/step-result actually produced', (
expect(JSON.stringify(recorded.data)).not.toContain('original')
// tool/call + tool/result correlate with the injected call id
const callEvent = agent.session.events.find(e => e.type === 'tool/call')!
expect((callEvent.data as any).callId).toBe('c-injected')
if (callEvent.type !== 'tool/call') throw new Error('wrong event type')
expect(callEvent.data.callId).toBe('c-injected')
// derived history shows the rewritten message (replay-correct)
const derived = agent.session.deriveMessages()
expect(JSON.stringify(derived)).toContain('rewritten')
@@ -105,27 +106,27 @@ describe('HIGH: abort during tool execution ends the turn', () => {
const ctx = await harness(adapter)
const executed: string[] = []
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
ctx.tools.register({
ctx.tools.register(defineTool({
name: 'aborter',
description: '',
parameters: { type: 'object' },
parameters: {},
async execute() {
executed.push('aborter')
agent.abort('user interrupt')
return [{ type: 'text', text: 'done' }]
},
})
ctx.tools.register({
}))
ctx.tools.register(defineTool({
name: 'second',
description: '',
parameters: { type: 'object' },
parameters: {},
async execute() {
executed.push('second')
return [{ type: 'text', text: 'done' }]
},
})
}))
const reasons: any[] = []
const reasons: TurnEndReason[] = []
ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason))
send(agent, 'go')
@@ -144,14 +145,14 @@ describe('HIGH: steering from late extension points is never stranded', () => {
textResponse('after steering'),
])
const ctx = await harness(adapter)
ctx.tools.register({
ctx.tools.register(defineTool({
name: 'echo',
description: '',
parameters: { type: 'object' },
async execute(args: any) {
parameters: { text: { type: 'string' } },
async execute(args) {
return [{ type: 'text', text: String(args.text) }]
},
})
}))
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
let steeredOnce = false
@@ -301,7 +302,7 @@ describe('MEDIUM: disposed status is part of the agent/status contract', () => {
}, { inject: ['agentLoop'] }))
const statuses: string[] = []
const reasons: any[] = []
const reasons: TurnEndReason[] = []
ctx.on('agent/status', (_agent, status) => void statuses.push(status))
ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason))
@@ -384,18 +385,18 @@ describe('MEDIUM: misc registry and config fixes', () => {
const adapter = new MockAdapter([toolCallResponse('c1', 'noop', {}), textResponse('done')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
ctx.tools.register({
ctx.tools.register(defineTool({
name: 'noop',
description: '',
parameters: { type: 'object' },
parameters: {},
async execute() {
agent.steer([{ type: 'text', text: 's' }], { source: { kind: 'plugin', plugin: 'goal' } })
return []
},
})
}))
const queuedSources: any[] = []
const steeringSources: any[] = []
const queuedSources: { source: MessageSource; steering: boolean }[] = []
const steeringSources: MessageSource[] = []
ctx.on('agent/queued', (_agent, _content, info) => void queuedSources.push(info))
ctx.on('agent/steering', (_agent, _turn, _content, source) => void steeringSources.push(source))

72
packages/agent/README.md Normal file
View File

@@ -0,0 +1,72 @@
# dsh-agent
Agent interface, registry, and `agent/*` event vocabulary. Every plugin (UI,
hooks, orchestrators) programs against the `Agent` handle defined here — it has
zero loop dependency, so the loop is swappable.
## Service: `AgentRegistry` (ctx key: `agents`)
Tracks live agents so UI, hook, and orchestrator plugins can find them without
importing the concrete loop package.
### Public API
- `ctx.agents.register(agent: Agent): () => void`
Register a live agent. Disposed with the calling fiber.
- `ctx.agents.get(id: string): Agent | undefined`
- `ctx.agents.list(): Agent[]`
### Events
The full `agent/*` event taxonomy is declared via declaration merging in
`dsh-agent` (not `dsh-agent-loop`), so plugins depend only on this package.
#### Lifecycle (emit)
- `agent/created`, `agent/disposed` — registration/deregistration
- `agent/status` — idle / running / disposed transition
- `agent/queued` — message entered inbox (source-resolved, steering flag)
#### Turn/step boundaries (emit)
- `agent/turn-start`, `agent/turn-end` (carries `TurnEndReason`)
- `agent/step-start`, `agent/step-end`
#### Interception seams (waterfall)
- `agent/request` — mutate `GenerateOptions` before the model call (hooks,
compaction, model switching, tool filtering)
- `agent/step-result` — post-process the assembled assistant message before tool
dispatch (validates what the log records)
- `agent/turn-continuation` — override the continue/stop decision
(force-continue /loop, force-stop budget guard)
#### Streaming + tool (emit)
- `agent/stream-chunk` — raw chunk from the model (token-level UI/log feed)
- `agent/steering` — steering content injected mid-turn
- `agent/error` — step/turn error
### Agent interface (`types.ts`)
The handle every plugin programs against:
- `agent.send(content, options?)` — queue a message; starts a turn when idle
- `agent.steer(content, options?)` — steer a running turn (inject between steps);
behaves like `send` when idle
- `agent.inject(content, options?)` — inject in-session context without triggering
a turn (context/message event); next request sees it
- `agent.abort(reason?)` — abort the in-flight step
- `agent.session`, `agent.status`, `agent.options`, `agent.id`
### Extension points
- Agent creation: `AgentLoop.create()` is the concrete implementation (in
`dsh-agent-loop`). Replace the loop by implementing `Agent` and registering
via `ctx.agents.register()`.
- Event listeners: all `agent/*` events are declared here — no dependency on the
loop package needed.
### What is NOT here (TODO)
- **Sub-agent spawn/fork** — seam on `AgentLoop.create()`, semantics deferred.

View File

@@ -1,3 +1,10 @@
/**
* Agent registry service. Tracks live agents so plugins can find them without
* depending on the concrete loop package. Agent creation belongs to the loop.
*
* @module @deepseek-ai/dsh-agent
*/
import { Context, Service } from 'cordis'
import type { Agent } from './types.ts'
@@ -22,7 +29,11 @@ export class AgentRegistry extends Service {
super(ctx, 'agents')
}
/** Register a live agent. Disposed with the calling fiber. */
/**
* Register a live agent. Throws if an agent with the same id is already
* registered. Emits `agent/created` on registration and `agent/disposed`
* when the calling fiber is disposed. Returns the disposer.
*/
register(agent: Agent): () => void {
return this.ctx.effect(() => {
if (this.store.has(agent.id)) {

View File

@@ -1,3 +1,14 @@
/**
* Agent interface and event taxonomy. Every plugin programs against the
* `Agent` handle defined here; the concrete implementation lives in
* `@deepseek-ai/dsh-agent-loop`.
*
* Merge-extensible: `AgentOptions` supports declaration merging for
* plugin-specific creation options.
*
* @module @deepseek-ai/dsh-agent/types
*/
import type { ContentBlock, GenerateOptions, Message, MessageSource, StreamChunk } from '@deepseek-ai/dsh-llm'
import type { Session, TurnEndReason } from '@deepseek-ai/dsh-session'

64
packages/llm/README.md Normal file
View File

@@ -0,0 +1,64 @@
# dsh-llm
Provider-neutral LLM vocabulary and abstract service. This package defines the
canonical language spoken by the agent loop, session logs, and every plugin.
## Service: `LlmService` (ctx key: `llm`)
An adapter registry plus streaming / non-streaming call surfaces. Both call
surfaces are interceptable via waterfall events.
### Public API
- `ctx.llm.registerAdapter(models: string[], adapter: LlmAdapter): () => void`
Register an adapter for the given model names. Disposed with the calling fiber.
- `ctx.llm.models(): string[]` — model names with a registered adapter.
- `ctx.llm.stream(options: GenerateOptions): AsyncIterable<StreamChunk>`
Stream one model call as raw chunks (token-level deltas).
- `ctx.llm.streamBlocks(options: GenerateOptions): AsyncIterable<ContentBlock>`
Stream as completed content blocks (convenience view).
- `ctx.llm.generate(options: GenerateOptions): Promise<GenerateResult>`
One model call, fully assembled.
### Events
| Event | Mode | Purpose |
|---|---|---|
| `llm/stream` | waterfall | Intercept/wrap every streaming model call (retry, caching, routing) |
| `llm/generate` | waterfall | Intercept/wrap every non-streaming model call |
| `llm/adapter-change` | emit | An adapter was registered or unregistered |
### Extension points
- Subclass `LlmAdapter` and call `ctx.llm.registerAdapter(models, adapter)`
to add a new model provider.
- Wrap `llm/stream` or `llm/generate` via `ctx.on()` waterfall listeners for
caching, retry, logging, rate-limiting, etc.
### Content-block vocabulary (`types.ts`)
Messages are arrays of typed content blocks: `text`, `reasoning`, `tool-call`,
`tool-result`, `image`. The union is derived from the merge-extensible
`ContentBlockMap`, so plugins can add block types via declaration merging.
Streaming is a raw chunk protocol (`block-start`, `text-delta`,
`reasoning-delta`, `tool-call-delta`, `block-end`, `usage`, `finish`).
`BlockAssembler` is the single shared implementation that assembles chunks into
blocks/messages.
### Classes
- `LlmAdapter` — abstract base class for provider adapters. The only required
method is `stream()`.
- `BlockAssembler` — incrementally assembles raw chunks into complete content
blocks and an assistant message. Used by the agent loop (raw chunks for replay
+ assembled for history) and by `streamBlocks()`/`generate()`.
- `LlmError` — typed error with a `code` string (`NO_ADAPTER`,
`DUPLICATE_ADAPTER`).
### What is NOT here (TODO)
- **DeepSeek V4 adapter** — the first real adapter lands in a later phase.
- **Streaming protocol review** — the chunk protocol has `TODO(review)` markers
and needs careful review before the first real adapter (DeepSeek V4 wire
format, partial JSON arguments, interleaved reasoning signatures, ...).

View File

@@ -1,3 +1,10 @@
/**
* Incremental chunk-to-message assembler. This is the single canonical assembly
* algorithm used by both the agent loop and the LLM service convenience views.
*
* @module @deepseek-ai/dsh-llm/assembler
*/
import type { ContentBlock, FinishReason, GenerateResult, Message, StreamChunk, TokenUsage } from './types.ts'
interface PartialBlock {

View File

@@ -1,3 +1,11 @@
/**
* LLM service: adapter registry with waterfall-interceptable streaming and
* non-streaming call surfaces. Exports the `LlmService` default, the abstract
* `LlmAdapter` for provider backends, and `BlockAssembler` for chunk assembly.
*
* @module @deepseek-ai/dsh-llm
*/
import { Context, Service } from 'cordis'
import type { ContentBlock, GenerateOptions, GenerateResult, StreamChunk } from './types.ts'
import { BlockAssembler } from './assembler.ts'
@@ -20,6 +28,7 @@ declare module 'cordis' {
}
}
/** Typed error for LLM-related failures. The `code` string enables programmatic handling. */
export class LlmError extends Error {
constructor(message: string, public code: string) {
super(message)
@@ -53,7 +62,12 @@ export class LlmService extends Service {
super(ctx, 'llm')
}
/** Register an adapter for the given model names. Disposed with the fiber. */
/**
* Register an adapter for the given model names. Throws `LlmError` with code
* `DUPLICATE_ADAPTER` if any model already has an adapter (all-or-nothing).
* Emits `llm/adapter-change` on registration and disposal. Disposed with the
* fiber.
*/
registerAdapter(models: string[], adapter: LlmAdapter): () => void {
return this.ctx.effect(() => {
for (const model of models) {
@@ -81,7 +95,11 @@ export class LlmService extends Service {
return adapter
}
/** Stream one model call as raw chunks (token-level deltas). */
/**
* Stream one model call as raw chunks (token-level deltas). Throws
* `LlmError` with code `NO_ADAPTER` if no adapter is registered for
* `options.model`. Dispatches through the `llm/stream` waterfall.
*/
stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
return this.ctx.waterfall(this, 'llm/stream', options, () => {
return this.adapter(options.model).stream(options)
@@ -105,7 +123,11 @@ export class LlmService extends Service {
yield * assembler.flushRemaining()
}
/** One model call, fully assembled (drains the chunk stream). */
/**
* One model call, fully assembled (drains the chunk stream). Dispatches
* through the `llm/generate` waterfall (and the inner stream through
* `llm/stream`). Same completion guarantees as `streamBlocks()`.
*/
generate(options: GenerateOptions): Promise<GenerateResult> {
return this.ctx.waterfall(this, 'llm/generate', options, async () => {
const assembler = new BlockAssembler()

View File

@@ -0,0 +1,65 @@
# dsh-session
Event-sourced session log and in-memory store. A `Session` is the append-only
source of truth for an agent's whole interaction history — the LLM message
history is *derived* from it.
## Service: `SessionStore` (ctx key: `sessions`)
Creates and holds event-sourced `Session` instances. Persistence is intentionally
not implemented here — plugins subscribe to `session/event` and flush on
`session/flush`.
### Public API
- `ctx.sessions.create(id?: string, seed?: SessionEvent[]): Session`
Create a session. `seed` replays/forks an existing event log. Disposed with
the calling fiber.
- `ctx.sessions.get(id: string): Session | undefined`
- `ctx.sessions.list(): Session[]`
### Events
| Event | Mode | Purpose |
|---|---|---|
| `session/created` | emit | A session was created |
| `session/event` | emit | An event was appended (sync, fire-and-forget) |
| `session/flush` | parallel | Awaited durability checkpoint (persistence plugins drain buffers here) |
### Class: `Session`
Plain class (not a Cordis Service). Create via `ctx.sessions.create()`.
- `session.append(type, data): SessionEvent` — synchronous, never blocks on I/O.
- `session.deriveMessages(): Message[]` — derive the LLM message history from
the event log. Raw `assistant/chunk` events are skipped; `context/message` and
`steering/message` render as tagged synthetic user messages.
- `session.events`, `session.seq`, `session.id`
### Session event vocabulary (`types.ts`)
The append-only log: `turn/start`, `turn/end`, `step/start`, `step/end`,
`user/message`, `assistant/message`, `assistant/chunk`, `tool/call`,
`tool/result`, `steering/message`, `context/message`, `usage`, `error`.
Merge-extensible via `SessionEventMap` — a compaction plugin adds
`compaction/marker`, etc.
Also defines `TurnTriggerMap` and `TurnEndReasonMap` (merge-extensible sum types
for typed turn boundaries — `kind`-tagged instead of strings).
### Extension points
- Persistence plugins: subscribe to `session/event` (write-behind) and drain on
`session/flush` (awaited) and fiber dispose. See
`examples/echo-agent/src/session-jsonl.ts` for the pattern.
- Replay/fork: `ctx.sessions.create(id, seed)` seeds a new session with an
existing event log.
### What is NOT here (TODO)
- **Real persistence backends** (JSONL per session dir, sqlite) — future phase.
- **Session event vocabulary review** — `TODO(review)` once the loop and a
persistence plugin coexist.
- **Session branching/tree** (pi-style entry tree) — defered unless needed beyond
seed-based forking.

View File

@@ -1,3 +1,11 @@
/**
* Event-sourced session service: append-only session log, in-memory store, and
* the derived LLM message history. Persistence is a plugin concern (subscribe
* to `session/event`, drain on `session/flush`).
*
* @module @deepseek-ai/dsh-session
*/
import { Context, Service } from 'cordis'
import type { ContentBlock, Message, MessageSource } from '@deepseek-ai/dsh-llm'
import type { SessionEvent, SessionEventMap, SessionEventType } from './types.ts'
@@ -64,7 +72,11 @@ export class Session {
return this.log.length
}
/** Append one event. Synchronous — the hot path never blocks on I/O. */
/**
* Append one typed event to the log and synchronously notify observers via
* `onAppend`. The hot path never blocks on I/O — persistence plugins buffer
* asynchronously.
*/
append<T extends SessionEventType>(type: T, data: SessionEventMap[T]): SessionEvent<T> {
const event = { type, seq: this.log.length, time: Date.now(), data } as SessionEvent<T>
this.log.push(event)
@@ -132,7 +144,12 @@ export class SessionStore extends Service {
super(ctx, 'sessions')
}
/** Create a session. `seed` replays/forks an existing event log. */
/**
* Create a session. If `seed` is provided, the session is populated with
* a copy of those events (replay/fork). The session is a Cordis effect:
* disposing the calling fiber stops event notification and removes the
* session from the store.
*/
create(id?: string, seed?: SessionEvent[]): Session {
id ??= `session-${++this.counter}`
if (this.store.has(id)) throw new Error(`session "${id}" already exists`)

View File

@@ -0,0 +1,49 @@
# dsh-system-prompt
System prompt assembly registry. Plugins contribute ordered text sections and
tool-schema providers; the agent loop calls `assemble()` once per step.
## Service: `SystemPrompt` (ctx key: `systemPrompt`)
### Public API
- `ctx.systemPrompt.section(section: PromptSection): () => void`
Contribute a section. Disposed with the calling fiber.
- `ctx.systemPrompt.tools(provider: () => ToolSchema[]): () => void`
Contribute tool schemas (evaluated at each assembly). Disposed with the calling
fiber.
- `ctx.systemPrompt.assemble(): Promise<PromptAssembly>`
Assemble the current prompt. Runs through the `system-prompt/assemble` waterfall.
### Events
| Event | Mode | Purpose |
|---|---|---|
| `system-prompt/assemble` | waterfall | Mutate/extend the assembly before it reaches the model |
| `system-prompt/change` | emit | A section or tool provider was registered or unregistered |
### Key types
- `PromptSection``{ name, order, text: string | (() => string) }`. Sections
are concatenated in ascending `order`.
- `PromptAssembly``{ sections: PromptSection[], tools: ToolSchema[] }`.
Tool schemas are part of the assembly by design: "what the model is told it
can do" is one coherent thing, even though adapters transmit schemas as a
separate wire field.
- `renderPrompt(assembly)` — joins section texts with blank lines.
Merge-extensible: plugins can declare extra fields on `PromptAssembly` via
declaration merging.
### Extension points
- Section providers: AGENTS.md reader, cwd notifier, persona config, etc.
- Tool schema providers: `ToolRegistry` registers itself as a tool provider
automatically.
- The `system-prompt/assemble` waterfall: mutate or replace the assembly
(system-prompt configurability, dynamic tool filtering).
### What is NOT here
- Any hardcoded prompt text — every section comes from plugins.
- Prompt compaction (belongs on the `agent/request` seam in `dsh-agent`).

View File

@@ -1,3 +1,11 @@
/**
* System prompt assembly registry. Plugins contribute ordered text sections and
* tool schema providers; `assemble()` collates them through a waterfall that
* runs once per step.
*
* @module @deepseek-ai/dsh-system-prompt
*/
import { Context, Service } from 'cordis'
import type { ToolSchema } from '@deepseek-ai/dsh-llm'
@@ -59,7 +67,11 @@ export class SystemPrompt extends Service {
super(ctx, 'systemPrompt')
}
/** Contribute a section. Disposed with the calling fiber. */
/**
* Contribute a text section to the system prompt. Order is determined by
* `section.order` (ascending). The section is removed when the calling
* fiber is disposed. Emits `system-prompt/change` on register/unregister.
*/
section(section: PromptSection): () => void {
return this.ctx.effect(() => {
this.sections.push(section)
@@ -72,7 +84,11 @@ export class SystemPrompt extends Service {
}, 'systemPrompt.section()')
}
/** Contribute tool schemas (evaluated at each assembly). Disposed with the fiber. */
/**
* Contribute a tool-schema provider that is evaluated at each assembly
* call (so it can reflect the live registry state). The provider is
* removed when the calling fiber is disposed. Emits `system-prompt/change`.
*/
tools(provider: () => ToolSchema[]): () => void {
return this.ctx.effect(() => {
this.toolProviders.push(provider)
@@ -85,7 +101,13 @@ export class SystemPrompt extends Service {
}, 'systemPrompt.tools()')
}
/** Assemble the current prompt (sections sorted, tools collected). */
/**
* Assemble the current prompt (sections sorted by order, tools collected
* from all providers). Runs through the `system-prompt/assemble` waterfall,
* giving listeners the opportunity to mutate or replace the assembly before
* it reaches the model. Await the result before reading the assembly values —
* waterfall listeners may be async.
*/
assemble(): Promise<PromptAssembly> {
const assembly: PromptAssembly = {
sections: [...this.sections].sort((a, b) => a.order - b.order),

83
packages/tools/README.md Normal file
View File

@@ -0,0 +1,83 @@
# dsh-tools
Tool registry and execution waterfall. Tool plugins register their schemas and
executors; the agent loop executes calls through the `tools/execute` waterfall.
## Service: `ToolRegistry` (ctx key: `tools`)
### Public API
- `ctx.tools.register(definition: ToolDefinition): () => void`
Register a tool. Disposed with the calling fiber.
- `ctx.tools.get(name: string): ToolDefinition | undefined`
- `ctx.tools.schemas(): ToolSchema[]`
Schemas of all registered tools (without the `execute` functions).
- `ctx.tools.execute(exec: ToolExecution): Promise<ToolExecutionResult>`
Execute one tool call through the `tools/execute` waterfall.
### Injected services
`SystemPrompt` — the registry automatically feeds its tool schemas into the
system-prompt assembly via `ctx.systemPrompt.tools()`.
### Events
| Event | Mode | Purpose |
|---|---|---|
| `tools/execute` | waterfall | Wrap/veto tool execution (sandbox, permission, hooks, plan mode) |
| `tools/change` | emit | A tool was registered or unregistered |
### Key types
- `ToolDefinition``ToolSchema` + `execute(args, exec): Promise<ContentBlock[]>`.
- `ToolExecution` — one pending tool call: `{ callId, name, arguments, agent?, signal? }`.
- `ToolExecutionResult` — outcome: `{ callId, content, isError }`.
### Extension points
- Tool plugins call `ctx.tools.register()` — schemas flow into the assembly
automatically.
- The `tools/execute` waterfall is the single seam for sandbox, permission,
hooks, and plan-mode plugins to wrap or veto a call. Listeners receive
`(exec, next)`: call `next()` to proceed, or return a result without calling
`next()` to short-circuit (veto).
- MCP servers: one plugin per server, discover tools, call
`ctx.tools.register()` with the server's schemas.
### Typed tool parameter schemas
First-party plugin authors can use the `defineTool()` helper (exported from this
package) for typed tool parameter schemas:
```ts
import { defineTool } from '@deepseek-ai/dsh-tools'
ctx.tools.register(defineTool({
name: 'read_file',
description: 'Read a file from disk.',
parameters: {
path: { type: 'string', required: true, description: 'Absolute file path' },
offset: { type: 'number' },
limit: { type: 'number' },
},
async execute(args, exec) {
// args is typed: { path: string; offset?: number; limit?: number }
const text = await readFile(args.path, 'utf8')
return [{ type: 'text', text }]
},
}))
```
The helper converts the author-facing `SchemaSpec` (with `required: true` as a
per-property boolean) to standard JSON Schema for the wire format. Raw
JSON-Schema tool definitions (from MCP servers) are still accepted by the
registry directly.
See `defineTool`, `SchemaSpec`, `InferArgs`, and `schemaSpecToJsonSchema` in the
public API for details.
### What is NOT here (TODO)
- **Tool shapes review** — when real tools land (e.g. a concurrency-safety hint
for parallel execution); phase 1 executes tool calls sequentially.
- **Parallel execution** — the loop currently iterates tool calls sequentially.

View File

@@ -1,8 +1,28 @@
/**
* Tool registry and execution waterfall. Plugins register tools; the registry
* feeds schemas into the system prompt, and `execute()` dispatches each call
* through the `tools/execute` waterfall for sandbox, permission, and hook
* plugins to wrap or veto.
*
* @module @deepseek-ai/dsh-tools
*/
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'
export {
defineTool,
schemaSpecToJsonSchema,
type SchemaSpec,
type SchemaProp,
type SchemaType,
type InferArgs,
type DefineToolOptions,
type JsonSchemaObject,
} from './schema.ts'
declare module 'cordis' {
interface Context {
tools: ToolRegistry
@@ -65,7 +85,12 @@ export class ToolRegistry extends Service {
ctx.systemPrompt.tools(() => this.schemas())
}
/** Register a tool. Disposed with the calling fiber. */
/**
* Register a tool. Throws if a tool with the same name is already
* registered. The tool's schema (minus the `execute` function) is
* automatically contributed to the system-prompt assembly. Disposed
* with the calling fiber. Emits `tools/change` on register/unregister.
*/
register(definition: ToolDefinition): () => void {
return this.ctx.effect(() => {
if (this.store.has(definition.name)) {
@@ -84,12 +109,21 @@ export class ToolRegistry extends Service {
return this.store.get(name)
}
/** Schemas of all registered tools (without the execute functions). */
/**
* Return all registered tool schemas, stripped of their `execute` functions.
* These are exactly what gets sent to the model via the system-prompt
* assembly.
*/
schemas(): ToolSchema[] {
return [...this.store.values()].map(({ execute, ...schema }) => schema)
}
/** Execute one tool call through the `tools/execute` waterfall. */
/**
* Execute one tool call through the `tools/execute` waterfall. If the tool
* is not registered, returns an `isError` result immediately (no waterfall).
* If the tool throws, the error is caught and returned as an `isError` result
* so the loop never sees an uncaught exception from a tool.
*/
execute(exec: ToolExecution): Promise<ToolExecutionResult> {
return this.ctx.waterfall(this, 'tools/execute', exec, async (): Promise<ToolExecutionResult> => {
const tool = this.store.get(exec.name)
@@ -103,10 +137,11 @@ export class ToolRegistry extends Service {
try {
const content = await tool.execute(exec.arguments, exec)
return { callId: exec.callId, content, isError: false }
} catch (error: any) {
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error)
return {
callId: exec.callId,
content: [{ type: 'text', text: `Error: ${error?.message ?? error}` }],
content: [{ type: 'text', text: `Error: ${message}` }],
isError: true,
}
}

View File

@@ -0,0 +1,222 @@
/**
* Typed tool-parameter schema DSL.
*
* Plugin authors write per-property specs with `required: true` as a boolean
* (the `SchemaSpec` type). A type-level helper (`InferArgs`) maps a SchemaSpec
* to the TS argument type. At runtime, `schemaSpecToJsonSchema()` converts a
* SchemaSpec to standard JSON Schema (`type: 'object'`, `properties`,
* `required` array) for the wire format sent to the model.
*
* # Why a custom DSL and not schemastery?
*
* Schemastery is a validation/transformation library (StandardSchema v1) used
* for plugin Config. Tool parameters need JSON Schema specifically (the LLM
* wire format), not validation. A lightweight DSL focused on JSON Schema
* generation, with type inference for the tool's `execute` args, gives plugin
* authors the best DX with the smallest surface area. Schemastery would add
* unnecessary indirection and wouldn't cleanly produce JSON Schema.
*
* @module dsh-tools/schema
*/
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { ToolDefinition, ToolExecution } from './index.ts'
// ---------------------------------------------------------------------------
// SchemaSpec — the author-facing per-property type
// ---------------------------------------------------------------------------
/** Valid JSON Schema primitive types for tool parameters. */
export type SchemaType = 'string' | 'number' | 'boolean' | 'object' | 'array'
/** One schema-spec property entry. */
export interface SchemaProp {
type: SchemaType
/** Per-property required flag (NOT the JSON Schema top-level required array). */
required?: true
/** Human-readable description, surfaced in the JSON Schema as well. */
description?: string
/** Enum of allowed values (strings only). */
enum?: string[]
/** Default value. */
default?: unknown
/** Nested properties for type: 'object'. */
properties?: SchemaSpec
/** Items schema for type: 'array'. */
items?: SchemaProp
}
/**
* The author-facing parameter schema: a shallow map of property name to
* {@link SchemaProp}. Required-ness is a per-property boolean (`required:
* true`), not a separate array.
*/
export type SchemaSpec = Record<string, SchemaProp>
// ---------------------------------------------------------------------------
// InferArgs — type-level mapping from SchemaSpec to TS argument type
// ---------------------------------------------------------------------------
/** Map a {@link SchemaType} to its TS primitive type. */
type TypeOf<T extends SchemaType> =
T extends 'string' ? string :
T extends 'number' ? number :
T extends 'boolean' ? boolean :
T extends 'object' ? Record<string, unknown> :
T extends 'array' ? unknown[] :
never
/**
* Infer the TS type of a single {@link SchemaProp}.
* - `required: true` → required (non-optional)
* - absent required → optional
* - `properties` on 'object' → recurse
*/
type InferProp<P extends SchemaProp> =
P extends { type: 'object'; properties: infer Sub extends SchemaSpec } ?
// Nested objects with their own SchemaSpec — infer their shape
(P extends { required: true } ? InferArgs<Sub> : InferArgs<Sub> | undefined) :
P extends { type: 'array'; items: infer Item extends SchemaProp } ?
// Arrays: infer item type
(P extends { required: true } ? TypeOf<Item['type']>[] : TypeOf<Item['type']>[] | undefined) :
// Primitive types
(P extends { required: true } ? TypeOf<P['type']> : TypeOf<P['type']> | undefined)
/**
* Infer the TS argument type for a complete {@link SchemaSpec}.
*
* Example:
* ```ts
* type Args = InferArgs<{ path: { type: 'string'; required: true }; limit: { type: 'number' } }>
* // → { path: string; limit?: number }
* ```
*/
export type InferArgs<S extends SchemaSpec> = {
[K in keyof S]: InferProp<S[K]>
}
// ---------------------------------------------------------------------------
// Runtime conversion: SchemaSpec → JSON Schema
// ---------------------------------------------------------------------------
/**
* Convert a single {@link SchemaProp} to its JSON Schema `properties` entry.
* The per-property `required` flag is collected; the caller builds the
* top-level `required` array.
*/
function propToJsonSchema(prop: SchemaProp): { schema: Record<string, unknown>; required: boolean } {
const result: Record<string, unknown> = { type: prop.type }
if (prop.description) result.description = prop.description
if (prop.enum) result.enum = prop.enum
if (prop.default !== undefined) result.default = prop.default
let required = prop.required === true
if (prop.type === 'object' && prop.properties) {
const nested = schemaSpecToJsonSchema(prop.properties)
result.properties = nested.properties
if (nested.required && nested.required.length > 0) {
result.required = nested.required
}
}
if (prop.type === 'array' && prop.items) {
const { schema: itemsSchema } = propToJsonSchema(prop.items)
result.items = itemsSchema
}
return { schema: result, required }
}
/** The return type of {@link schemaSpecToJsonSchema}. */
export interface JsonSchemaObject {
type: 'object'
properties: Record<string, unknown>
required?: string[]
}
/**
* Convert a {@link SchemaSpec} to standard JSON Schema (`type: 'object'`,
* `properties`, `required` array).
*
* This is a plain function — no schemastery or other framework dependency.
*/
export function schemaSpecToJsonSchema(spec: SchemaSpec): JsonSchemaObject {
const properties: Record<string, unknown> = {}
const required: string[] = []
for (const [key, prop] of Object.entries(spec)) {
const { schema, required: isRequired } = propToJsonSchema(prop)
properties[key] = schema
if (isRequired) required.push(key)
}
const result: JsonSchemaObject = {
type: 'object',
properties,
}
if (required.length > 0) result.required = required
return result
}
// ---------------------------------------------------------------------------
// defineTool — typed helper for first-party plugin authors
// ---------------------------------------------------------------------------
/** Options for {@link defineTool}. */
export interface DefineToolOptions<S extends SchemaSpec> {
/** Tool name (must be unique). */
name: string
/** Human-readable description sent to the model. */
description: string
/**
* Parameter schema using the per-property-required DSL. Converted to
* standard JSON Schema at runtime.
*/
parameters: S
/**
* Tool execution function. `args` is typed as {@link InferArgs<S>} — zero
* casts needed.
*/
execute(args: InferArgs<S>, exec: ToolExecution): Promise<ContentBlock[]>
/** Whether the tool requires structured output (default false). */
strict?: boolean
}
/**
* Define a tool with a typed parameter schema.
*
* Use this instead of constructing a raw {@link ToolDefinition} for all
* first-party tools. The `parameters` use the boolean-required style
* (`required: true` as a per-property flag), and `execute` receives typed
* args derived from the schema.
*
* ```ts
* const tool = defineTool({
* name: 'read_file',
* description: 'Read a file from disk.',
* parameters: {
* path: { type: 'string', required: true, description: 'Absolute file path' },
* offset: { type: 'number' },
* limit: { type: 'number', description: 'Max lines to read' },
* },
* async execute(args) {
* // args: { path: string; offset?: number; limit?: number }
* },
* })
* ```
*
* Raw JSON-Schema tool definitions (from MCP servers) are still accepted
* by `ToolRegistry.register()` directly — `defineTool` is sugar for
* first-party plugin authors.
*/
export function defineTool<S extends SchemaSpec>(options: DefineToolOptions<S>): ToolDefinition {
return {
name: options.name,
description: options.description,
parameters: schemaSpecToJsonSchema(options.parameters) as unknown as Record<string, unknown>,
strict: options.strict,
execute: options.execute as ToolDefinition['execute'],
}
}

View File

@@ -1,7 +1,7 @@
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'
import ToolRegistry, { defineTool, schemaSpecToJsonSchema, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
async function setup() {
const ctx = new Context()
@@ -10,14 +10,14 @@ async function setup() {
return ctx
}
const echoTool = {
const echoTool = defineTool({
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 ?? '') }]
parameters: { text: { type: 'string' } },
async execute(args) {
return [{ type: 'text' as const, text: String(args.text ?? '') }]
},
}
})
describe('ToolRegistry', () => {
it('registers tools, exposes schemas, and feeds the system-prompt assembly', async () => {
@@ -29,8 +29,9 @@ describe('ToolRegistry', () => {
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()
// schemas() result must not leak execute — as any intentional: 'execute'
// is deliberately absent from ToolSchema, we're testing it's not there
expect((ctx.tools.schemas()[0] as Record<string, unknown>).execute).toBeUndefined()
const assembly = await ctx.systemPrompt.assemble()
expect(assembly.tools.map(t => t.name)).toEqual(['echo'])
@@ -118,3 +119,183 @@ describe('ToolRegistry', () => {
expect(ctx.tools.schemas().map(t => t.name)).toEqual(['echo'])
})
})
describe('defineTool / schema DSL', () => {
it('converts SchemaSpec to standard JSON Schema with required array', () => {
const spec = {
path: { type: 'string', required: true as const, description: 'Absolute path' },
offset: { type: 'number' },
limit: { type: 'number', description: 'Max lines' },
}
const jsonSchema = schemaSpecToJsonSchema(spec)
expect(jsonSchema).toEqual({
type: 'object',
properties: {
path: { type: 'string', description: 'Absolute path' },
offset: { type: 'number' },
limit: { type: 'number', description: 'Max lines' },
},
required: ['path'],
})
})
it('handles empty spec (no properties, no required)', () => {
expect(schemaSpecToJsonSchema({})).toEqual({
type: 'object',
properties: {},
})
})
it('handles nested object spec', () => {
const spec = {
config: {
type: 'object' as const,
required: true as const,
properties: {
host: { type: 'string', required: true as const },
port: { type: 'number' },
},
},
}
const jsonSchema = schemaSpecToJsonSchema(spec)
expect(jsonSchema).toEqual({
type: 'object',
properties: {
config: {
type: 'object',
properties: {
host: { type: 'string' },
port: { type: 'number' },
},
required: ['host'],
},
},
required: ['config'],
})
})
it('defineTool returns a valid ToolDefinition with typed execute', async () => {
const ctx = await setup()
const tool = defineTool({
name: 'typed-echo',
description: 'A typed echo tool',
parameters: {
text: { type: 'string', required: true },
uppercase: { type: 'boolean' },
},
async execute(args) {
// args is typed: { text: string; uppercase?: boolean }
const result = args.uppercase ? args.text.toUpperCase() : args.text
return [{ type: 'text', text: result }]
},
})
ctx.tools.register(tool)
expect(ctx.tools.schemas()).toEqual([{
name: 'typed-echo',
description: 'A typed echo tool',
parameters: {
type: 'object',
properties: {
text: { type: 'string' },
uppercase: { type: 'boolean' },
},
required: ['text'],
},
}])
const result = await ctx.tools.execute({
callId: 'c1',
name: 'typed-echo',
arguments: { text: 'hello', uppercase: true },
})
expect(result.isError).toBe(false)
expect(result.content).toEqual([{ type: 'text', text: 'HELLO' }])
})
it('type-level: InferArgs maps required properties to non-optional', () => {
// Compile-time check: if this compiles, InferArgs is correct.
// args.a is string (required), args.b is number|undefined (optional).
const tool = defineTool({
name: 'type-check',
description: '',
parameters: { a: { type: 'string' as const, required: true as const }, b: { type: 'number' as const } },
async execute(args) {
// Verify types at runtime via typeof
expect(typeof args.a).toBe('string')
// args.b should be undefined when not provided
void args
return [{ type: 'text', text: args.a }]
},
})
void tool
})
it('registry round-trips a defineTool definition (register→schemas→execute)', async () => {
const ctx = await setup()
ctx.tools.register(defineTool({
name: 'roundtrip',
description: 'Round-trip test',
parameters: {
req: { type: 'string', required: true },
opt: { type: 'number', description: 'Optional number' },
},
async execute(args) {
return [{ type: 'text', text: `${args.req}:${args.opt ?? 'none'}` }]
},
}))
// Schema round-trip: schemas() returns standard JSON Schema
const schemas = ctx.tools.schemas()
expect(schemas).toHaveLength(1)
expect(schemas[0].parameters).toEqual({
type: 'object',
properties: {
req: { type: 'string' },
opt: { type: 'number', description: 'Optional number' },
},
required: ['req'],
})
// Execution round-trip
const result = await ctx.tools.execute({
callId: 'c1',
name: 'roundtrip',
arguments: { req: 'hello' },
})
expect(result.isError).toBe(false)
expect(result.content).toEqual([{ type: 'text', text: 'hello:none' }])
})
it('still accepts raw JSON-Schema ToolDefinition directly (MCP interop)', async () => {
const ctx = await setup()
ctx.tools.register({
name: 'raw-tool',
description: 'Raw JSON Schema tool (like an MCP adapter would register)',
parameters: {
type: 'object',
properties: { path: { type: 'string' } },
required: ['path'],
},
async execute(args: unknown) {
const p = args as { path: string }
return [{ type: 'text', text: p.path }]
},
})
const schemas = ctx.tools.schemas()
expect(schemas[0].parameters).toEqual({
type: 'object',
properties: { path: { type: 'string' } },
required: ['path'],
})
const result = await ctx.tools.execute({
callId: 'c1',
name: 'raw-tool',
arguments: { path: '/tmp' },
})
expect(result.isError).toBe(false)
expect(result.content).toEqual([{ type: 'text', text: '/tmp' }])
})
})

View File

@@ -11,7 +11,6 @@
"esModuleInterop": true,
"allowImportingTsExtensions": true,
"strict": true,
"noImplicitAny": false,
"types": ["node"]
}
}

13
vendor/AGENTS.md vendored Normal file
View File

@@ -0,0 +1,13 @@
# AGENTS.md — Vendored Packages
This directory contains source-vendored copies of the Cordis framework and its
foundation libraries. See `vendor/README.md` for the manifest, local-modification
log, and the upstream sync procedure.
**Do NOT edit `vendor/*/src/` files casually.** Every local divergence from
upstream must be logged exhaustively in `vendor/README.md` under "Local
modifications." The `vendor/*/tsconfig.json` files are the exception —
regenerated to fit the monorepo build, and they may be touched for type-checking
policy changes (e.g., `noImplicitAny`).
When changes are unavoidable, follow the sync procedure in `vendor/README.md`.

1
vendor/CLAUDE.md vendored Symbolic link
View File

@@ -0,0 +1 @@
AGENTS.md

View File

@@ -2,7 +2,8 @@
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib"
"outDir": "lib",
"noImplicitAny": false
},
"include": ["src"],
"references": [

View File

@@ -2,7 +2,8 @@
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib"
"outDir": "lib",
"noImplicitAny": false
},
"include": ["src"],
"references": [