From 7f024a1a9d6bfa0c092386db795149a8346ed232 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:39:27 +0800 Subject: [PATCH] Document the codebase thoroughly and tighten type safety MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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, 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. --- AGENTS.md | 24 ++ README.md | 2 +- examples/README.md | 17 ++ examples/echo-agent/README.md | 40 ++++ examples/echo-agent/src/echo-tool.ts | 14 +- examples/echo-agent/src/stdio-chat.ts | 6 +- packages/AGENTS.md | 27 +++ packages/CLAUDE.md | 1 + packages/README.md | 56 +++++ packages/agent-loop/README.md | 83 +++++++ packages/agent-loop/src/agent.ts | 15 +- packages/agent-loop/src/inbox.ts | 8 + packages/agent-loop/src/index.ts | 9 + packages/agent-loop/src/loop.ts | 44 +++- packages/agent-loop/tests/loop.spec.ts | 54 ++--- .../agent-loop/tests/review-fixes.spec.ts | 49 ++-- packages/agent/README.md | 72 ++++++ packages/agent/src/index.ts | 13 +- packages/agent/src/types.ts | 11 + packages/llm/README.md | 64 +++++ packages/llm/src/assembler.ts | 7 + packages/llm/src/index.ts | 28 ++- packages/session/README.md | 65 +++++ packages/session/src/index.ts | 21 +- packages/system-prompt/README.md | 49 ++++ packages/system-prompt/src/index.ts | 28 ++- packages/tools/README.md | 83 +++++++ packages/tools/src/index.ts | 45 +++- packages/tools/src/schema.ts | 222 ++++++++++++++++++ packages/tools/tests/tools.spec.ts | 197 +++++++++++++++- tsconfig.base.json | 1 - vendor/AGENTS.md | 13 + vendor/CLAUDE.md | 1 + vendor/include/tsconfig.json | 3 +- vendor/loader/tsconfig.json | 3 +- 35 files changed, 1274 insertions(+), 101 deletions(-) create mode 100644 examples/README.md create mode 100644 examples/echo-agent/README.md create mode 100644 packages/AGENTS.md create mode 120000 packages/CLAUDE.md create mode 100644 packages/README.md create mode 100644 packages/agent-loop/README.md create mode 100644 packages/agent/README.md create mode 100644 packages/llm/README.md create mode 100644 packages/session/README.md create mode 100644 packages/system-prompt/README.md create mode 100644 packages/tools/README.md create mode 100644 packages/tools/src/schema.ts create mode 100644 vendor/AGENTS.md create mode 120000 vendor/CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md index 50e65bea21..2860b8701f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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` 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 diff --git a/README.md b/README.md index 61dc4e6a06..f08bcc096a 100644 --- a/README.md +++ b/README.md @@ -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/). diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000000..9c9b091aed --- /dev/null +++ b/examples/README.md @@ -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 " to trigger a tool call round-trip. diff --git a/examples/echo-agent/README.md b/examples/echo-agent/README.md new file mode 100644 index 0000000000..03f11eecfb --- /dev/null +++ b/examples/echo-agent/README.md @@ -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 " +- `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 " 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 `.jsonl` in the `examples/echo-agent/` +directory. Clean up with: `rm -f examples/echo-agent/*.jsonl` diff --git a/examples/echo-agent/src/echo-tool.ts b/examples/echo-agent/src/echo-tool.ts index 89dcebfe6b..dfdcb9b001 100644 --- a/examples/echo-agent/src/echo-tool.ts +++ b/examples/echo-agent/src/echo-tool.ts @@ -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()}` }] }, - }) + })) } diff --git a/examples/echo-agent/src/stdio-chat.ts b/examples/echo-agent/src/stdio-chat.ts index 936eeac13c..4826ef539d 100644 --- a/examples/echo-agent/src/stdio-chat.ts +++ b/examples/echo-agent/src/stdio-chat.ts @@ -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 `) } }) diff --git a/packages/AGENTS.md b/packages/AGENTS.md new file mode 100644 index 0000000000..dc7aa6b5ec --- /dev/null +++ b/packages/AGENTS.md @@ -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//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. diff --git a/packages/CLAUDE.md b/packages/CLAUDE.md new file mode 120000 index 0000000000..47dc3e3d86 --- /dev/null +++ b/packages/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/packages/README.md b/packages/README.md new file mode 100644 index 0000000000..dd38752a6f --- /dev/null +++ b/packages/README.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//tests/*.spec.ts`. Every + registry needs an HMR-safety test. Err on the side of more tests. diff --git a/packages/agent-loop/README.md b/packages/agent-loop/README.md new file mode 100644 index 0000000000..42eda325c1 --- /dev/null +++ b/packages/agent-loop/README.md @@ -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 diff --git a/packages/agent-loop/src/agent.ts b/packages/agent-loop/src/agent.ts index f109f2cc61..3edabb0405 100644 --- a/packages/agent-loop/src/agent.ts +++ b/packages/agent-loop/src/agent.ts @@ -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), diff --git a/packages/agent-loop/src/inbox.ts b/packages/agent-loop/src/inbox.ts index 483a2a62de..7aabad0166 100644 --- a/packages/agent-loop/src/inbox.ts +++ b/packages/agent-loop/src/inbox.ts @@ -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. */ diff --git a/packages/agent-loop/src/index.ts b/packages/agent-loop/src/index.ts index 345380052f..6484ec9ace 100644 --- a/packages/agent-loop/src/index.ts +++ b/packages/agent-loop/src/index.ts @@ -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' diff --git a/packages/agent-loop/src/loop.ts b/packages/agent-loop/src/loop.ts index ebcc92f15d..cbec5c886d 100644 --- a/packages/agent-loop/src/loop.ts +++ b/packages/agent-loop/src/loop.ts @@ -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) } } diff --git a/packages/agent-loop/tests/loop.spec.ts b/packages/agent-loop/tests/loop.spec.ts index 98483048b0..03351c57c2 100644 --- a/packages/agent-loop/tests/loop.spec.ts +++ b/packages/agent-loop/tests/loop.spec.ts @@ -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 => 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) diff --git a/packages/agent-loop/tests/review-fixes.spec.ts b/packages/agent-loop/tests/review-fixes.spec.ts index 5a7af9058d..7954ab03d8 100644 --- a/packages/agent-loop/tests/review-fixes.spec.ts +++ b/packages/agent-loop/tests/review-fixes.spec.ts @@ -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)) diff --git a/packages/agent/README.md b/packages/agent/README.md new file mode 100644 index 0000000000..ff21c41708 --- /dev/null +++ b/packages/agent/README.md @@ -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. diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index df232fd0f9..95aca7311d 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -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)) { diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts index b531776d78..f54f8d5d8e 100644 --- a/packages/agent/src/types.ts +++ b/packages/agent/src/types.ts @@ -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' diff --git a/packages/llm/README.md b/packages/llm/README.md new file mode 100644 index 0000000000..22996f7605 --- /dev/null +++ b/packages/llm/README.md @@ -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` + Stream one model call as raw chunks (token-level deltas). +- `ctx.llm.streamBlocks(options: GenerateOptions): AsyncIterable` + Stream as completed content blocks (convenience view). +- `ctx.llm.generate(options: GenerateOptions): Promise` + 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, ...). diff --git a/packages/llm/src/assembler.ts b/packages/llm/src/assembler.ts index c7f23a3eda..815225f973 100644 --- a/packages/llm/src/assembler.ts +++ b/packages/llm/src/assembler.ts @@ -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 { diff --git a/packages/llm/src/index.ts b/packages/llm/src/index.ts index 65165d48f8..578d08ac7b 100644 --- a/packages/llm/src/index.ts +++ b/packages/llm/src/index.ts @@ -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 { 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 { return this.ctx.waterfall(this, 'llm/generate', options, async () => { const assembler = new BlockAssembler() diff --git a/packages/session/README.md b/packages/session/README.md new file mode 100644 index 0000000000..3af8523282 --- /dev/null +++ b/packages/session/README.md @@ -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. diff --git a/packages/session/src/index.ts b/packages/session/src/index.ts index b5dabf8854..3f8a7e9f98 100644 --- a/packages/session/src/index.ts +++ b/packages/session/src/index.ts @@ -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(type: T, data: SessionEventMap[T]): SessionEvent { const event = { type, seq: this.log.length, time: Date.now(), data } as SessionEvent 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`) diff --git a/packages/system-prompt/README.md b/packages/system-prompt/README.md new file mode 100644 index 0000000000..979606488d --- /dev/null +++ b/packages/system-prompt/README.md @@ -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` + 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`). diff --git a/packages/system-prompt/src/index.ts b/packages/system-prompt/src/index.ts index 7069ef5b18..212020b48c 100644 --- a/packages/system-prompt/src/index.ts +++ b/packages/system-prompt/src/index.ts @@ -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 { const assembly: PromptAssembly = { sections: [...this.sections].sort((a, b) => a.order - b.order), diff --git a/packages/tools/README.md b/packages/tools/README.md new file mode 100644 index 0000000000..848c77b1b5 --- /dev/null +++ b/packages/tools/README.md @@ -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` + 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`. +- `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. diff --git a/packages/tools/src/index.ts b/packages/tools/src/index.ts index b93f20c354..85dd3b7e01 100644 --- a/packages/tools/src/index.ts +++ b/packages/tools/src/index.ts @@ -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 { return this.ctx.waterfall(this, 'tools/execute', exec, async (): Promise => { 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, } } diff --git a/packages/tools/src/schema.ts b/packages/tools/src/schema.ts new file mode 100644 index 0000000000..92e3afdb84 --- /dev/null +++ b/packages/tools/src/schema.ts @@ -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 + +// --------------------------------------------------------------------------- +// InferArgs — type-level mapping from SchemaSpec to TS argument type +// --------------------------------------------------------------------------- + +/** Map a {@link SchemaType} to its TS primitive type. */ +type TypeOf = + T extends 'string' ? string : + T extends 'number' ? number : + T extends 'boolean' ? boolean : + T extends 'object' ? Record : + 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 { type: 'object'; properties: infer Sub extends SchemaSpec } ? + // Nested objects with their own SchemaSpec — infer their shape + (P extends { required: true } ? InferArgs : InferArgs | undefined) : + P extends { type: 'array'; items: infer Item extends SchemaProp } ? + // Arrays: infer item type + (P extends { required: true } ? TypeOf[] : TypeOf[] | undefined) : + // Primitive types + (P extends { required: true } ? TypeOf : TypeOf | 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 = { + [K in keyof S]: InferProp +} + +// --------------------------------------------------------------------------- +// 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; required: boolean } { + const result: Record = { 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 + 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 = {} + 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 { + /** 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} — zero + * casts needed. + */ + execute(args: InferArgs, exec: ToolExecution): Promise + /** 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(options: DefineToolOptions): ToolDefinition { + return { + name: options.name, + description: options.description, + parameters: schemaSpecToJsonSchema(options.parameters) as unknown as Record, + strict: options.strict, + execute: options.execute as ToolDefinition['execute'], + } +} diff --git a/packages/tools/tests/tools.spec.ts b/packages/tools/tests/tools.spec.ts index e06b1795de..ebca94079a 100644 --- a/packages/tools/tests/tools.spec.ts +++ b/packages/tools/tests/tools.spec.ts @@ -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).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' }]) + }) +}) diff --git a/tsconfig.base.json b/tsconfig.base.json index edb0d3cccd..d00194930b 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -11,7 +11,6 @@ "esModuleInterop": true, "allowImportingTsExtensions": true, "strict": true, - "noImplicitAny": false, "types": ["node"] } } diff --git a/vendor/AGENTS.md b/vendor/AGENTS.md new file mode 100644 index 0000000000..6abdb5c117 --- /dev/null +++ b/vendor/AGENTS.md @@ -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`. diff --git a/vendor/CLAUDE.md b/vendor/CLAUDE.md new file mode 120000 index 0000000000..47dc3e3d86 --- /dev/null +++ b/vendor/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/vendor/include/tsconfig.json b/vendor/include/tsconfig.json index 96be18685e..f61e7c582c 100644 --- a/vendor/include/tsconfig.json +++ b/vendor/include/tsconfig.json @@ -2,7 +2,8 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib" + "outDir": "lib", + "noImplicitAny": false }, "include": ["src"], "references": [ diff --git a/vendor/loader/tsconfig.json b/vendor/loader/tsconfig.json index 5748cb0c7b..c25587b8de 100644 --- a/vendor/loader/tsconfig.json +++ b/vendor/loader/tsconfig.json @@ -2,7 +2,8 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib" + "outDir": "lib", + "noImplicitAny": false }, "include": ["src"], "references": [