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:
@@ -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
|
||||
|
||||
@@ -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/).
|
||||
@@ -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.
|
||||
@@ -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`
|
||||
@@ -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()}` }]
|
||||
},
|
||||
})
|
||||
}))
|
||||
}
|
||||
@@ -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 `)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
AGENTS.md
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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),
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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, ...).
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
@@ -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`)
|
||||
|
||||
@@ -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`).
|
||||
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
}
|
||||
}
|
||||
@@ -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' }])
|
||||
})
|
||||
})
|
||||
@@ -11,7 +11,6 @@
|
||||
"esModuleInterop": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"strict": true,
|
||||
"noImplicitAny": false,
|
||||
"types": ["node"]
|
||||
}
|
||||
}
|
||||
Vendored
+13
@@ -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
@@ -0,0 +1 @@
|
||||
AGENTS.md
|
||||
Vendored
+2
-1
@@ -2,7 +2,8 @@
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib"
|
||||
"outDir": "lib",
|
||||
"noImplicitAny": false
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
|
||||
Vendored
+2
-1
@@ -2,7 +2,8 @@
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib"
|
||||
"outDir": "lib",
|
||||
"noImplicitAny": false
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
|
||||
Reference in New Issue
Block a user