Merge remote-tracking branch 'origin/master' into codex/skill-system
This commit is contained in:
@@ -39,12 +39,11 @@ This is the [interface/implementation/consumer seam](../../../docs/rfc/implement
|
||||
|
||||
```ts
|
||||
import type { Config } from '@deepseek-ai/dsh-agent-core'
|
||||
// { agents?, persona?, skills? } — the schema is z.intersect([AgentLoop.Config,
|
||||
// SystemPrompt.Config, { skills }]), so validation and defaulting can never
|
||||
// drift from the owners'.
|
||||
// { agents?, persona?, toolOrder?, skills? } — the schema intersects the owner schemas,
|
||||
// so validation and defaulting can never drift from the owners.
|
||||
```
|
||||
|
||||
The bundle FORWARDS each field to the child that owns it: `agents` to `agent-loop` (default `[]`), so each app supplies its own pre-created agents — a stdio app pre-creates a `main`; the ACP app pre-creates none (it creates agents on demand at `session/new`) — `persona` to `dsh-system-prompt` (default `''`), and `skills.registry` / `skills.local` to the skill registry and local provider. Forwarding is exactly why the owners can live in the shared spine even though the apps disagree on what to configure.
|
||||
The bundle FORWARDS each field to the child that owns it: `agents` to `agent-loop` (default `[]`), so each app supplies its own pre-created agents — a stdio app pre-creates a `main`; the ACP app pre-creates none (it creates agents on demand at `session/new`) — `persona` to `dsh-system-prompt` (default `''`), the deployment's persona section; `toolOrder` to `dsh-system-prompt` (absent — lexicographic), the explicit model-facing tool order; and `skills.registry` / `skills.local` to the skill registry and local provider. Forwarding is exactly why the owners can live in the shared spine even though the apps disagree on what to configure.
|
||||
|
||||
## Why a code bundle, not a shared YAML include
|
||||
|
||||
|
||||
@@ -73,17 +73,21 @@ export interface SkillConfig {
|
||||
/**
|
||||
* Bundle config: each field forwarded verbatim to the child that owns it —
|
||||
* `agents` to the agent loop (an app that pre-creates no agents, like the ACP
|
||||
* bridge, simply omits it), `persona` to the system-prompt plugin (the
|
||||
* deployment's persona section), and `skills` to the skill registry/local
|
||||
* provider. All three are optional INPUT here because each owner's schema supplies the default
|
||||
* (`[]` / `''` / the DSH skill roots); the schema is the INTERSECTION of the
|
||||
* owners' own schemas, so validation and defaulting can never drift from them.
|
||||
* bridge, simply omits it), `persona` and `toolOrder` to the system-prompt
|
||||
* plugin (the deployment's persona section and the explicit model-facing tool
|
||||
* order), and `skills` to the skill registry/local provider. Every field is
|
||||
* optional INPUT here because each owner's schema supplies the default (`[]` /
|
||||
* `''` / absent — lexicographic / the DSH skill roots); the schema is the
|
||||
* INTERSECTION of the owners' own schemas, so validation and defaulting can
|
||||
* never drift from them.
|
||||
*/
|
||||
export interface Config {
|
||||
/** The agent-loop `agents` list (see dsh-agent-loop's `Config`). */
|
||||
agents?: AgentLoopConfig['agents']
|
||||
/** The deployment persona (see dsh-system-prompt's `Config`). */
|
||||
persona?: SystemPromptConfig['persona']
|
||||
/** The explicit model-facing tool order (see dsh-system-prompt's `Config`). */
|
||||
toolOrder?: SystemPromptConfig['toolOrder']
|
||||
/** Skill registry and local provider config. */
|
||||
skills?: SkillConfig
|
||||
}
|
||||
@@ -104,11 +108,11 @@ export const Config = z.intersect([
|
||||
/**
|
||||
* Load the spine. Each `ctx.plugin(...)` mounts one child of the bundle fiber;
|
||||
* `agent-loop` receives the forwarded `agents` list and `system-prompt` the
|
||||
* forwarded `persona`. Load order is irrelevant (cordis pends each fiber on
|
||||
* its `inject` until the services it needs exist), but the listing mirrors the
|
||||
* dependency layering for readability: the LLM vocabulary and core registries
|
||||
* first, then the dev tripwire and the bash tool consumer, then the loop that
|
||||
* drives them.
|
||||
* forwarded `persona` and `toolOrder`. Load order is irrelevant (cordis pends
|
||||
* each fiber on its `inject` until the services it needs exist), but the
|
||||
* listing mirrors the dependency layering for readability: the LLM vocabulary
|
||||
* and core registries first, then the dev tripwire and the bash tool consumer,
|
||||
* then the loop that drives them.
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
ctx.plugin(Timer)
|
||||
@@ -117,8 +121,13 @@ export function apply(ctx: Context, config: Config): void {
|
||||
// The forwarded fields are validated + defaulted by this bundle's intersected
|
||||
// schema before apply runs, so the ?? fallbacks only narrow the
|
||||
// optional-input TYPES — they mirror the owners' schema defaults, never
|
||||
// introduce different ones.
|
||||
ctx.plugin(SystemPrompt, { persona: config.persona ?? '' })
|
||||
// introduce different ones. toolOrder has no owner-supplied default value —
|
||||
// ABSENT means "lexicographic order" — so it is forwarded conditionally
|
||||
// rather than via ??.
|
||||
ctx.plugin(SystemPrompt, {
|
||||
persona: config.persona ?? '',
|
||||
...config.toolOrder !== undefined ? { toolOrder: config.toolOrder } : {},
|
||||
})
|
||||
ctx.plugin(ToolRegistry)
|
||||
ctx.plugin(SkillService, config.skills?.registry ?? {})
|
||||
ctx.plugin(SkillLocal, config.skills?.local ?? {})
|
||||
|
||||
@@ -4,6 +4,7 @@ import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
|
||||
import * as agentCore from '../src/index.ts'
|
||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
@@ -152,6 +153,23 @@ describe('dsh-agent-core bundle', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('forwards toolOrder to the system-prompt assembly', async () => {
|
||||
const ctx = await mount({ toolOrder: ['zulu', TOOL_ORDER_REST] })
|
||||
// The bundle's own bash tools pend on the absent `ctx.bash` executor in
|
||||
// this providerless mount, so register two plain tools to order.
|
||||
for (const name of ['alpha', 'zulu']) {
|
||||
ctx.get('tools')!.register({
|
||||
name,
|
||||
description: name,
|
||||
parameters: {},
|
||||
execute: async () => [],
|
||||
})
|
||||
}
|
||||
const assembly = await ctx.get('systemPrompt')!.assemble()
|
||||
expect(assembly.tools.map(tool => tool.name)).toEqual(['zulu', 'alpha', 'skill'])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('re-exports the loop config schema as its own', () => {
|
||||
expect(agentCore.Config).toBeDefined()
|
||||
expect(agentCore.name).toBe('agent-core')
|
||||
|
||||
458
packages/core/agent-core/tests/gen-config-catalog.spec.ts
Normal file
458
packages/core/agent-core/tests/gen-config-catalog.spec.ts
Normal file
@@ -0,0 +1,458 @@
|
||||
/**
|
||||
* Negative-path tests for the config catalog generator (`scripts/gen-config-catalog.ts`).
|
||||
*
|
||||
* The generated catalog is frozen by a regenerate-and-diff freshness gate, so
|
||||
* the freshness half is exercised by `pnpm run verify-config-catalog` in CI.
|
||||
* What a freshness diff CANNOT prove is that the generator REJECTS malformed
|
||||
* source the way it promises to — an unclassifiable package, an undocumented
|
||||
* config field, a schema key the config type does not declare, or a referenced
|
||||
* type name that resolves nowhere. These tests drive `collectConfigCatalog()`
|
||||
* against synthetic fixture packages to prove each guard fires (and that
|
||||
* well-formed packages classify and extract correctly), mirroring the
|
||||
* negative tests for gen-cordis-catalog. The spec lives in this package
|
||||
* because agent-core is the config-composition plugin (its schema is the
|
||||
* intersection of its children's), the shape the generator's cross-package
|
||||
* folding exists for.
|
||||
*/
|
||||
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { collectConfigCatalog, render } from '../../../../scripts/gen-config-catalog.ts'
|
||||
|
||||
/** Write one fixture package (package.json + src files) under a scan root. */
|
||||
function writePkg(root: string, dir: string, name: string, files: Record<string, string>): void {
|
||||
const pkgDir = join(root, 'packages', dir)
|
||||
mkdirSync(join(pkgDir, 'src'), { recursive: true })
|
||||
writeFileSync(join(pkgDir, 'package.json'), JSON.stringify({ name }))
|
||||
for (const [rel, text] of Object.entries(files)) writeFileSync(join(pkgDir, rel), text)
|
||||
}
|
||||
|
||||
const roots: string[] = []
|
||||
const makeRoot = (): string => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'config-catalog-'))
|
||||
roots.push(root)
|
||||
return root
|
||||
}
|
||||
/** One-package fixture: the common case. */
|
||||
const make = (files: Record<string, string>, name = '@fix/one'): string => {
|
||||
const root = makeRoot()
|
||||
writePkg(root, 'group/one', name, files)
|
||||
return root
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
const DOCUMENTED_CONFIG = `/** Fixture config. */
|
||||
export interface Config {
|
||||
/** A knob. */
|
||||
knob?: string
|
||||
}
|
||||
`
|
||||
|
||||
describe('gen-config-catalog classification', () => {
|
||||
it('classifies an apply plugin with a config parameter and extracts the paste', () => {
|
||||
const entries = collectConfigCatalog(make({
|
||||
'src/index.ts': `import type { Context } from 'cordis'
|
||||
export const inject = ['tools']
|
||||
${DOCUMENTED_CONFIG}
|
||||
/** Load. */
|
||||
export function apply(ctx: Context, config: Config): void {}
|
||||
`,
|
||||
}))
|
||||
expect(entries).toHaveLength(1)
|
||||
expect(entries[0]).toMatchObject({ pkg: '@fix/one', kind: 'config', configTypeName: 'Config', inject: ['tools'] })
|
||||
expect(entries[0]?.pastes?.[0]?.text).toContain('/** A knob. */')
|
||||
})
|
||||
|
||||
it('classifies a default service class, reading its constructor and static inject', () => {
|
||||
const entries = collectConfigCatalog(make({
|
||||
'src/index.ts': `import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
${DOCUMENTED_CONFIG}
|
||||
/** Fixture service. */
|
||||
export default class Fix {
|
||||
static inject = ['llm']
|
||||
static Config = z.object({ knob: z.string() }) as unknown as z<Config>
|
||||
constructor(ctx: Context, config: Config) {}
|
||||
}
|
||||
`,
|
||||
}))
|
||||
expect(entries[0]).toMatchObject({ kind: 'config', className: 'Fix', inject: ['llm'], schemaKeys: ['knob'] })
|
||||
})
|
||||
|
||||
it('classifies an abstract default class as a seam', () => {
|
||||
const entries = collectConfigCatalog(make({
|
||||
'src/index.ts': 'export default abstract class FixSeam { abstract run(): void }\n',
|
||||
}))
|
||||
expect(entries[0]).toMatchObject({ kind: 'seam', className: 'FixSeam' })
|
||||
})
|
||||
|
||||
it('classifies a plugin whose apply takes no config as no-config', () => {
|
||||
const entries = collectConfigCatalog(make({
|
||||
'src/index.ts': 'import type { Context } from \'cordis\'\n/** Load. */\nexport function apply(ctx: Context): void {}\n',
|
||||
}))
|
||||
expect(entries[0]?.kind).toBe('no-config')
|
||||
})
|
||||
|
||||
it('classifies a module with neither default export nor apply as a library', () => {
|
||||
const entries = collectConfigCatalog(make({
|
||||
'src/index.ts': 'export const helper = 1\n',
|
||||
}))
|
||||
expect(entries[0]?.kind).toBe('library')
|
||||
})
|
||||
|
||||
it('hard-errors on a package with no entry file', () => {
|
||||
const root = makeRoot()
|
||||
mkdirSync(join(root, 'packages', 'group', 'one'), { recursive: true })
|
||||
writeFileSync(join(root, 'packages', 'group', 'one', 'package.json'), JSON.stringify({ name: '@fix/one' }))
|
||||
expect(() => collectConfigCatalog(root)).toThrow(/entry .* is missing or unreadable/)
|
||||
})
|
||||
|
||||
it('hard-errors on a package.json without a name', () => {
|
||||
const root = makeRoot()
|
||||
mkdirSync(join(root, 'packages', 'group', 'one', 'src'), { recursive: true })
|
||||
writeFileSync(join(root, 'packages', 'group', 'one', 'package.json'), '{}')
|
||||
expect(() => collectConfigCatalog(root)).toThrow(/has no "name"/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('gen-config-catalog config extraction guards', () => {
|
||||
it('hard-errors on a config field with no JSDoc prose', () => {
|
||||
expect(() => collectConfigCatalog(make({
|
||||
'src/index.ts': `import type { Context } from 'cordis'
|
||||
export interface Config {
|
||||
knob?: string
|
||||
}
|
||||
/** Load. */
|
||||
export function apply(ctx: Context, config: Config): void {}
|
||||
`,
|
||||
}))).toThrow(/config field 'Config\.knob' .* has no JSDoc prose/)
|
||||
})
|
||||
|
||||
it('hard-errors on an undocumented field nested in a type literal', () => {
|
||||
expect(() => collectConfigCatalog(make({
|
||||
'src/index.ts': `import type { Context } from 'cordis'
|
||||
/** Fixture config. */
|
||||
export interface Config {
|
||||
/** Entries. */
|
||||
entries: {
|
||||
id: string
|
||||
}[]
|
||||
}
|
||||
/** Load. */
|
||||
export function apply(ctx: Context, config: Config): void {}
|
||||
`,
|
||||
}))).toThrow(/config field 'Config\.entries\.id' .* has no JSDoc prose/)
|
||||
})
|
||||
|
||||
it('pastes a package-local type transitively and records external refs', () => {
|
||||
const entries = collectConfigCatalog(make({
|
||||
'src/index.ts': `import type { Context } from 'cordis'
|
||||
import type { Mode } from './types.ts'
|
||||
import type { Remote } from '@fix/dep'
|
||||
/** Fixture config. */
|
||||
export interface Config {
|
||||
/** The mode. */
|
||||
mode?: Mode
|
||||
/** The remote. */
|
||||
remote?: Remote
|
||||
}
|
||||
/** Load. */
|
||||
export function apply(ctx: Context, config: Config): void {}
|
||||
`,
|
||||
'src/types.ts': '/** Fixture mode. */\nexport type Mode = \'a\' | \'b\'\n',
|
||||
}))
|
||||
expect(entries[0]?.pastes?.map(p => p.source)).toEqual([
|
||||
'packages/group/one/src/index.ts:5',
|
||||
'packages/group/one/src/types.ts:2',
|
||||
])
|
||||
expect(entries[0]?.refs).toEqual([{ alias: 'Remote', imported: 'Remote', specifier: '@fix/dep' }])
|
||||
})
|
||||
|
||||
it('hard-errors on a referenced type name that resolves nowhere', () => {
|
||||
expect(() => collectConfigCatalog(make({
|
||||
'src/index.ts': `import type { Context } from 'cordis'
|
||||
/** Fixture config. */
|
||||
export interface Config {
|
||||
/** The ghost. */
|
||||
ghost?: Ghost
|
||||
}
|
||||
/** Load. */
|
||||
export function apply(ctx: Context, config: Config): void {}
|
||||
`,
|
||||
}))).toThrow(/references 'Ghost' .* neither declared in the package, imported, nor a known global/)
|
||||
})
|
||||
|
||||
it('hard-errors on a config type imported from another package', () => {
|
||||
expect(() => collectConfigCatalog(make({
|
||||
'src/index.ts': `import type { Context } from 'cordis'
|
||||
import type { Config } from '@fix/dep'
|
||||
/** Load. */
|
||||
export function apply(ctx: Context, config: Config): void {}
|
||||
`,
|
||||
}))).toThrow(/config type 'Config' is imported from '@fix\/dep'/)
|
||||
})
|
||||
|
||||
it('hard-errors when one name resolves to two different declarations across the closure', () => {
|
||||
expect(() => collectConfigCatalog(make({
|
||||
'src/index.ts': `import type { Context } from 'cordis'
|
||||
import type { A } from './a.ts'
|
||||
import type { B } from './b.ts'
|
||||
/** Fixture config. */
|
||||
export interface Config {
|
||||
/** A. */
|
||||
a?: A
|
||||
/** B. */
|
||||
b?: B
|
||||
}
|
||||
/** Load. */
|
||||
export function apply(ctx: Context, config: Config): void {}
|
||||
`,
|
||||
'src/a.ts': '/** First Option. */\nexport interface Option {\n /** X. */\n x?: string\n}\n/** A. */\nexport interface A {\n /** O. */\n o?: Option\n}\n',
|
||||
'src/b.ts': '/** Second Option. */\nexport interface Option {\n /** Y. */\n y?: string\n}\n/** B. */\nexport interface B {\n /** O. */\n o?: Option\n}\n',
|
||||
}))).toThrow(/type name 'Option' resolves to two different declarations/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('gen-config-catalog schema cross-check', () => {
|
||||
it('accepts a chained schema whose keys all appear on the config type', () => {
|
||||
const entries = collectConfigCatalog(make({
|
||||
'src/index.ts': `import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
${DOCUMENTED_CONFIG}
|
||||
export const Config: z<Config> = z.object({ knob: z.string() }).default({})
|
||||
/** Load. */
|
||||
export function apply(ctx: Context, config: Config): void {}
|
||||
`,
|
||||
}))
|
||||
expect(entries[0]?.schemaKeys).toEqual(['knob'])
|
||||
})
|
||||
|
||||
it('hard-errors on a schema key the config type does not declare', () => {
|
||||
expect(() => collectConfigCatalog(make({
|
||||
'src/index.ts': `import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
${DOCUMENTED_CONFIG}
|
||||
export const Config: z<Config> = z.object({ knob: z.string(), hidden: z.number() })
|
||||
/** Load. */
|
||||
export function apply(ctx: Context, config: Config): void {}
|
||||
`,
|
||||
}))).toThrow(/schema validates key 'hidden' but config type 'Config' declares no such member/)
|
||||
})
|
||||
|
||||
it('hard-errors on a NESTED schema key the config type does not declare', () => {
|
||||
expect(() => collectConfigCatalog(make({
|
||||
'src/index.ts': `import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
/** Fixture config. */
|
||||
export interface Config {
|
||||
/** Entries. */
|
||||
entries: {
|
||||
/** Id. */
|
||||
id: string
|
||||
}[]
|
||||
}
|
||||
export const Config: z<Config> = z.object({ entries: z.array(z.object({ id: z.string(), ghost: z.string() })) })
|
||||
/** Load. */
|
||||
export function apply(ctx: Context, config: Config): void {}
|
||||
`,
|
||||
}))).toThrow(/schema validates key 'entries\[\]\.ghost'/)
|
||||
})
|
||||
|
||||
it('resolves nested keys through a workspace-imported intersection part (re-export chains included)', () => {
|
||||
const root = makeRoot()
|
||||
writePkg(root, 'group/dep', '@fix/dep', {
|
||||
'src/index.ts': 'export * from \'./types.ts\'\n',
|
||||
'src/types.ts': '/** Shared options. */\nexport interface Opts {\n /** Model. */\n model?: string\n}\n',
|
||||
})
|
||||
writePkg(root, 'group/one', '@fix/one', {
|
||||
'src/index.ts': `import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type { Opts } from '@fix/dep'
|
||||
/** Fixture config. */
|
||||
export interface Config {
|
||||
/** Entries. */
|
||||
entries: (Opts & {
|
||||
/** Id. */
|
||||
id: string
|
||||
})[]
|
||||
}
|
||||
export const Config: z<Config> = z.object({ entries: z.array(z.object({ id: z.string(), model: z.string() })) })
|
||||
/** Load. */
|
||||
export function apply(ctx: Context, config: Config): void {}
|
||||
`,
|
||||
})
|
||||
expect(() => collectConfigCatalog(root)).not.toThrow()
|
||||
})
|
||||
|
||||
it('resolves nested keys through a Partial<> wrapper', () => {
|
||||
expect(() => collectConfigCatalog(make({
|
||||
'src/index.ts': `import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
/** Caps. */
|
||||
export interface Caps {
|
||||
/** X. */
|
||||
x?: boolean
|
||||
}
|
||||
/** Fixture config. */
|
||||
export interface Config {
|
||||
/** Capabilities. */
|
||||
capabilities?: Partial<Caps>
|
||||
}
|
||||
export const Config: z<Config> = z.object({ capabilities: z.object({ x: z.boolean() }) })
|
||||
/** Load. */
|
||||
export function apply(ctx: Context, config: Config): void {}
|
||||
`,
|
||||
}))).not.toThrow()
|
||||
})
|
||||
|
||||
it('leaves a nested key under an external (unresolvable) type unreported', () => {
|
||||
expect(() => collectConfigCatalog(make({
|
||||
'src/index.ts': `import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type { External } from 'some-external-pkg'
|
||||
/** Fixture config. */
|
||||
export interface Config {
|
||||
/** Options. */
|
||||
options?: External
|
||||
}
|
||||
export const Config: z<Config> = z.object({ options: z.object({ whatever: z.string() }) })
|
||||
/** Load. */
|
||||
export function apply(ctx: Context, config: Config): void {}
|
||||
`,
|
||||
}))).not.toThrow()
|
||||
})
|
||||
|
||||
it('folds an intersected workspace schema into the subset check', () => {
|
||||
const root = makeRoot()
|
||||
writePkg(root, 'group/leaf', '@fix/leaf', {
|
||||
'src/index.ts': `import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
/** Leaf config. */
|
||||
export interface Config {
|
||||
/** Leaf knob. */
|
||||
leaf?: string
|
||||
}
|
||||
/** Leaf service. */
|
||||
export default class Leaf {
|
||||
static Config = z.object({ leaf: z.string() }) as unknown as z<Config>
|
||||
constructor(ctx: Context, config: Config) {}
|
||||
}
|
||||
`,
|
||||
})
|
||||
writePkg(root, 'group/bundle', '@fix/bundle', {
|
||||
'src/index.ts': `import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import Leaf from '@fix/leaf'
|
||||
/** Bundle config. */
|
||||
export interface Config {
|
||||
/** Forwarded leaf knob. */
|
||||
leaf?: string
|
||||
}
|
||||
export const Config = z.intersect([Leaf.Config]) as unknown as z<Config>
|
||||
/** Load. */
|
||||
export function apply(ctx: Context, config: Config): void {}
|
||||
`,
|
||||
})
|
||||
const entries = collectConfigCatalog(root)
|
||||
expect(entries.find(e => e.pkg === '@fix/bundle')?.schemaComposes).toEqual(['@fix/leaf'])
|
||||
})
|
||||
|
||||
it('resolves composed nested keys through an indexed-access forwarder', () => {
|
||||
const root = makeRoot()
|
||||
writePkg(root, 'group/leaf', '@fix/leaf', {
|
||||
'src/index.ts': `import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
/** Leaf config. */
|
||||
export interface Config {
|
||||
/** Agents. */
|
||||
agents: {
|
||||
/** Id. */
|
||||
id: string
|
||||
}[]
|
||||
}
|
||||
/** Leaf service. */
|
||||
export default class Leaf {
|
||||
static Config = z.object({ agents: z.array(z.object({ id: z.string() })) }) as unknown as z<Config>
|
||||
constructor(ctx: Context, config: Config) {}
|
||||
}
|
||||
`,
|
||||
})
|
||||
writePkg(root, 'group/bundle', '@fix/bundle', {
|
||||
'src/index.ts': `import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import Leaf, { type Config as LeafConfig } from '@fix/leaf'
|
||||
/** Bundle config forwarding the leaf's agents list. */
|
||||
export interface Config {
|
||||
/** Forwarded agents list. */
|
||||
agents?: LeafConfig['agents']
|
||||
}
|
||||
export const Config = z.intersect([Leaf.Config]) as unknown as z<Config>
|
||||
/** Load. */
|
||||
export function apply(ctx: Context, config: Config): void {}
|
||||
`,
|
||||
})
|
||||
expect(() => collectConfigCatalog(root)).not.toThrow()
|
||||
})
|
||||
|
||||
it('hard-errors when an intersected schema key is missing from the bundle config type', () => {
|
||||
const root = makeRoot()
|
||||
writePkg(root, 'group/leaf', '@fix/leaf', {
|
||||
'src/index.ts': `import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
/** Leaf config. */
|
||||
export interface Config {
|
||||
/** Leaf knob. */
|
||||
leaf?: string
|
||||
}
|
||||
/** Leaf service. */
|
||||
export default class Leaf {
|
||||
static Config = z.object({ leaf: z.string() }) as unknown as z<Config>
|
||||
constructor(ctx: Context, config: Config) {}
|
||||
}
|
||||
`,
|
||||
})
|
||||
writePkg(root, 'group/bundle', '@fix/bundle', {
|
||||
'src/index.ts': `import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import Leaf from '@fix/leaf'
|
||||
/** Bundle config that forgot to declare the forwarded field. */
|
||||
export interface Config {
|
||||
/** Unrelated. */
|
||||
other?: string
|
||||
}
|
||||
export const Config = z.intersect([Leaf.Config]) as unknown as z<Config>
|
||||
/** Load. */
|
||||
export function apply(ctx: Context, config: Config): void {}
|
||||
`,
|
||||
})
|
||||
expect(() => collectConfigCatalog(root)).toThrow(/schema validates key 'leaf' but config type 'Config' declares no such member/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('gen-config-catalog render', () => {
|
||||
it('renders sections, fences, and the terse classification lists', () => {
|
||||
const root = makeRoot()
|
||||
writePkg(root, 'group/one', '@fix/one', {
|
||||
'src/index.ts': `import type { Context } from 'cordis'
|
||||
${DOCUMENTED_CONFIG}
|
||||
/** Load. */
|
||||
export function apply(ctx: Context, config: Config): void {}
|
||||
`,
|
||||
})
|
||||
writePkg(root, 'group/lib', '@fix/lib', { 'src/index.ts': 'export const helper = 1\n' })
|
||||
writePkg(root, 'group/seam', '@fix/seam', {
|
||||
'src/index.ts': 'export default abstract class Seam { abstract run(): void }\n',
|
||||
})
|
||||
const page = render(collectConfigCatalog(root))
|
||||
expect(page).toContain('## `@fix/one`')
|
||||
expect(page).toContain('```ts config-catalog')
|
||||
expect(page).toContain('/** A knob. */')
|
||||
expect(page).toContain('- `@fix/lib` ([`packages/group/lib/src/index.ts`](../packages/group/lib/src/index.ts))')
|
||||
expect(page).toContain('- `@fix/seam` — abstract `Seam`')
|
||||
})
|
||||
})
|
||||
@@ -56,12 +56,15 @@ forever:
|
||||
STEP loop:
|
||||
drain steering
|
||||
assembly = systemPrompt.assemble({agent}) ⟵ renderPrompt(assembly) IS the full prompt
|
||||
await serial agent/pre-step ⟵ surface mutation (compaction) outside the step
|
||||
prefix ??= waterfall agent/session-prefix ⟵ once per instance (first step): frozen
|
||||
session prefix; on the header, never history
|
||||
await serial agent/pre-step(…, prefix) ⟵ surface mutation (compaction) outside the step;
|
||||
pressure gates see the prefix the request carries
|
||||
boundary = session.deriveMessages() ⟵ reconstruction boundary: same sync frame,
|
||||
session('step/start') strictly before step/start
|
||||
config = waterfall agent/request ⟵ frozen seed; return a replacement to switch
|
||||
session('request/header'[-delta]) ⟵ the header event this request owes the log
|
||||
stream llm.stream(freeze({header..., messages: boundary})) → session('assistant/chunk')
|
||||
stream llm.stream(freeze({header..., messages: prefix+boundary})) → session('assistant/chunk')
|
||||
message = waterfall agent/step-result
|
||||
session('assistant/message')
|
||||
each tool-call: session('tool/call')
|
||||
@@ -85,7 +88,7 @@ Cancellation: `agent.cancel()` is the single public stop primitive — it clears
|
||||
### 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/session-start`, `agent/prompt-submit`, `agent/pre-step`, `agent/request`, `agent/step-result`, `tools/pre-execute`, `tools/post-execute`, `agent/turn-continuation`
|
||||
- Hooks: `agent/session-start`, `agent/prompt-submit`, `agent/pre-step`, `agent/request`, `agent/session-prefix`, `agent/step-result`, `tools/pre-execute`, `tools/post-execute`, `agent/turn-continuation`
|
||||
- Compaction: `agent/pre-step`
|
||||
- Sandbox, permission, plan mode: `tools/pre-execute` (deny/ask gate), `tools/post-execute`
|
||||
- Sub-agents: implemented outside the loop as `ctx.subagents` providers; in-process providers use `ctx.agents.create()` and owned `AgentHandle` teardown, while child streaming/progress and background/poll collection remain deferred.
|
||||
|
||||
@@ -22,6 +22,10 @@ import { isTurnOpen, lastTurnNumber, runLoop } from './loop.ts'
|
||||
* the agent/* event taxonomy — plugins never need this class.
|
||||
*/
|
||||
export class ReactLoopAgent implements Agent {
|
||||
/**
|
||||
* The queued + steering FIFOs behind {@link send}/{@link steer}. Public so
|
||||
* the driver loop can drain it; {@link cancel} clears it wholesale.
|
||||
*/
|
||||
readonly inbox = new Inbox()
|
||||
|
||||
private _status: AgentStatus = 'idle'
|
||||
@@ -256,6 +260,8 @@ export class ReactLoopAgent implements Agent {
|
||||
* promise (unblocking the idle wait), releases any `whenIdle` waiters, and
|
||||
* aborts the current request if any. The returned `agent.done` promise
|
||||
* resolves once the loop exits.
|
||||
* @returns the disposer — idempotent and infallible (it runs inside the
|
||||
* fiber's LIFO disposal chain, where a throw would skip later disposers).
|
||||
*/
|
||||
start(): () => void {
|
||||
this.done = runLoop(this.ctx, this, {
|
||||
|
||||
@@ -24,30 +24,47 @@ export class Inbox {
|
||||
private steeringMessages: InboxMessage[] = []
|
||||
private wakeup: (() => void) | undefined
|
||||
|
||||
/** Resolves when a queued message arrives (used by the idle loop). */
|
||||
/** True while queued messages are pending — read by the idle wait's fast path and the loop's turn-start checks. */
|
||||
get hasQueued(): boolean {
|
||||
return this.queuedMessages.length > 0
|
||||
}
|
||||
|
||||
/** True while steering messages are pending — read by `cancel()`'s arm gate and the loop's stop-override check. */
|
||||
get hasSteering(): boolean {
|
||||
return this.steeringMessages.length > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a message to the queued FIFO and wake a parked {@link waitForQueued}.
|
||||
* @param message - the message to queue for the next turn start.
|
||||
*/
|
||||
enqueue(message: InboxMessage): void {
|
||||
this.queuedMessages.push(message)
|
||||
this.wakeup?.()
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a message to the steering FIFO. Deliberately no wakeup: steering is
|
||||
* drained between steps of a running turn, never by the idle wait —
|
||||
* `Agent.steer()` on an idle agent falls back to `send()` instead.
|
||||
* @param message - the message to inject between steps of the running turn.
|
||||
*/
|
||||
steer(message: InboxMessage): void {
|
||||
this.steeringMessages.push(message)
|
||||
}
|
||||
|
||||
/** Drain all queued messages (turn start). */
|
||||
/**
|
||||
* Drain all queued messages (turn start).
|
||||
* @returns the drained messages in arrival order; the queued FIFO is left empty.
|
||||
*/
|
||||
drainQueued(): InboxMessage[] {
|
||||
return this.queuedMessages.splice(0)
|
||||
}
|
||||
|
||||
/** Drain all steering messages (between steps). */
|
||||
/**
|
||||
* Drain all steering messages (between steps).
|
||||
* @returns the drained messages in arrival order; the steering FIFO is left empty.
|
||||
*/
|
||||
drainSteering(): InboxMessage[] {
|
||||
return this.steeringMessages.splice(0)
|
||||
}
|
||||
@@ -62,7 +79,12 @@ export class Inbox {
|
||||
this.steeringMessages.length = 0
|
||||
}
|
||||
|
||||
/** Wait until a queued message arrives or `cancel` resolves. */
|
||||
/**
|
||||
* Wait until a queued message arrives or `cancel` resolves.
|
||||
* @param cancel - a promise whose resolution abandons the wait without a
|
||||
* message (the driver loop passes the agent's disposed promise so a parked
|
||||
* loop can exit).
|
||||
*/
|
||||
waitForQueued(cancel: Promise<void>): Promise<void> {
|
||||
if (this.hasQueued) return Promise.resolve()
|
||||
const { promise, resolve } = Promise.withResolvers<void>()
|
||||
|
||||
@@ -29,9 +29,14 @@ declare module 'cordis' {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin config: the agents to create — or resume, via `resumeSessionId` —
|
||||
* declaratively at startup, so a cordis.yml deployment needs no code.
|
||||
*/
|
||||
export interface Config {
|
||||
/** Agents created from configuration at startup. */
|
||||
agents: (AgentOptions & {
|
||||
/** Agent id to register under; also seeds the fresh per-run session id (`${id}-session-<uuid>`). */
|
||||
id: AgentId
|
||||
/** Optional workspace cwd for the config-created fresh session. */
|
||||
cwd?: string
|
||||
|
||||
@@ -157,13 +157,17 @@ export interface LoopHandle {
|
||||
* drain steering → session('steering/message') ⟵ catches late steering
|
||||
* assembly = ctx.systemPrompt.assemble({agent}) ⟵ waterfall system-prompt/assemble; renderPrompt
|
||||
* (persona section + {{variables}}) IS the full prompt
|
||||
* await ctx.serial('agent/pre-step') ⟵ surface mutation (compaction) OUTSIDE the step
|
||||
* prefix ??= waterfall agent/session-prefix ⟵ once per loop instance (first step): frozen
|
||||
* session prefix; logged on the header, never
|
||||
* session history
|
||||
* await ctx.serial('agent/pre-step', …, prefix) ⟵ surface mutation (compaction) OUTSIDE the step;
|
||||
* pressure gates see the prefix the request carries
|
||||
* boundary = session.deriveMessages() ⟵ the reconstruction boundary: snapshot in the
|
||||
* session('step/start') same sync frame, strictly before step/start
|
||||
* config = waterfall agent/request(config) ⟵ frozen seed; a returned replacement switches
|
||||
* session('request/header'|'request/header-delta') ⟵ the header event this request owes the
|
||||
* log (initial/resume anchor, delta, fallback)
|
||||
* req = freeze({header..., messages: boundary, sessionId, signal})
|
||||
* req = freeze({header..., messages: prefix+boundary, sessionId, signal})
|
||||
* stream ctx.llm.stream(req) ⟵ waterfall llm/stream (raw chunks, frozen req)
|
||||
* session('assistant/chunk')
|
||||
* msg = waterfall agent/step-result ⟵ BEFORE the log append, so the
|
||||
@@ -185,6 +189,9 @@ export interface LoopHandle {
|
||||
* re-enqueue leftover steering as queued ⟵ steering is never stranded
|
||||
* idle (emit agent/status) unless more queued
|
||||
* ```
|
||||
* @param ctx - the plugin context the loop reaches events (agent/…, session/flush) and services (systemPrompt, llm, tools) through.
|
||||
* @param agent - the agent this invocation drives for its whole lifetime (its inbox, session, and options).
|
||||
* @param handle - the bridge to the agent's mutable state: status/abort setters plus the disposal and cancel-marker reads.
|
||||
*/
|
||||
export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle): Promise<void> {
|
||||
// Per-instance transmission bookkeeping: whether THIS loop instance has
|
||||
@@ -469,6 +476,50 @@ async function runTurn(
|
||||
break
|
||||
}
|
||||
|
||||
// Compose the session prefix ONCE per loop instance, lazily before the
|
||||
// instance's first pre-step: request-only messages placed in front of
|
||||
// the ENTIRE derived history on every request this instance sends. It
|
||||
// MUST precede the pre-step seam so compaction gates on THIS instance's
|
||||
// prefix — reading a previous instance's logged prefix would let a
|
||||
// resumed/forked instance whose contributor grew skip compaction and
|
||||
// ship an over-window first request. The result is deep-cloned
|
||||
// (decoupled from listener-held references), deep-frozen, and cached on
|
||||
// the transmission bookkeeping, so reuse is structural — the prefix
|
||||
// cannot change mid-session and the provider prefix cache holds by
|
||||
// construction (resume = a new instance = a recompose, anchored by its
|
||||
// 'resume' snapshot). The prefix is not session history — the header
|
||||
// event in runStep is its only durable record
|
||||
// (EpochHeader.messagePrefix). The frozen empty seed serves both the
|
||||
// listener chain and the no-listener fallback: a contribution is a
|
||||
// RETURNED extension of `await next()`, never an in-place push. This
|
||||
// runs OUTSIDE the step, before the boundary snapshot: a composing
|
||||
// listener's session append lands before the boundary and joins the
|
||||
// CURRENT request.
|
||||
if (transmission.sessionPrefix === undefined) {
|
||||
const emptyPrefix: Message[] = deepFreeze([])
|
||||
const composed = await ctx.waterfall(
|
||||
'agent/session-prefix', agent, emptyPrefix, abort.signal,
|
||||
() => Promise.resolve(emptyPrefix),
|
||||
)
|
||||
|
||||
// Interruption landing during prefix composition: mirror the assembly
|
||||
// window above — drop the about-to-start step without running the
|
||||
// seam, and DISCARD the composition instead of caching it. An
|
||||
// abort-aware listener may have returned a degraded fallback under
|
||||
// the firing signal; committing it would ship a prefix no request
|
||||
// ever used (and no header ever logged) on this instance's next real
|
||||
// request. The next turn recomposes under a live signal — the cache
|
||||
// only ever holds a fully composed prefix. The cache-hit path needs
|
||||
// no such check: nothing awaits between the assembly check above and
|
||||
// the pre-step seam.
|
||||
if (handle.isCancelled() || handle.isDisposed()) {
|
||||
handle.setAbort(undefined)
|
||||
reason = handle.isDisposed() ? { kind: 'disposed' } : { kind: 'aborted', reason: handle.cancelReason() }
|
||||
break
|
||||
}
|
||||
transmission.sessionPrefix = deepFreeze(structuredClone(composed))
|
||||
}
|
||||
|
||||
// Pre-step surface-mutation checkpoint (compaction), fired OUTSIDE the
|
||||
// step: after `turn/start` (and the prior step's close) but before
|
||||
// `step/start`, so a compaction's log-only `compact/*` records and its
|
||||
@@ -479,8 +530,10 @@ async function runTurn(
|
||||
// concurrent listeners cannot interleave their `session.append`s. A
|
||||
// throwing listener escapes to the outer catch, which closes the (not-yet-
|
||||
// open) step as a no-op and ends the turn via failTurn — a broken
|
||||
// pre-step plugin ends the turn, not the loop.
|
||||
await ctx.serial('agent/pre-step', agent, turn, step, fullSystemPrompt, abort.signal)
|
||||
// pre-step plugin ends the turn, not the loop. The composed session
|
||||
// prefix rides along so token-pressure listeners count everything the
|
||||
// request will actually carry.
|
||||
await ctx.serial('agent/pre-step', agent, turn, step, fullSystemPrompt, transmission.sessionPrefix, abort.signal)
|
||||
|
||||
// Interruption landing during the pre-step seam: do not open an empty step.
|
||||
if (handle.isCancelled() || handle.isDisposed()) {
|
||||
@@ -671,11 +724,12 @@ function drainSteering(agent: ReactLoopAgent, turn: number): boolean {
|
||||
}
|
||||
|
||||
/** One step: build the request from the boundary snapshot + the step's
|
||||
* header → log the header event the request owes → stream model → record →
|
||||
* execute tools. The caller assembles the system prompt, fires the
|
||||
* `agent/pre-step` seam, snapshots the derivation, and opens the step BEFORE
|
||||
* calling this, so `boundaryMessages` is exactly the surface prefix at
|
||||
* step/start and already reflects any compaction. */
|
||||
* header → compose the session prefix if this instance has none yet → log
|
||||
* the header event the request owes → stream model → record → execute
|
||||
* tools. The caller assembles the
|
||||
* system prompt, fires the `agent/pre-step` seam, snapshots the derivation,
|
||||
* and opens the step BEFORE calling this, so `boundaryMessages` is exactly
|
||||
* the surface prefix at step/start and already reflects any compaction. */
|
||||
async function runStep(
|
||||
ctx: Context,
|
||||
agent: ReactLoopAgent,
|
||||
@@ -715,22 +769,30 @@ async function runStep(
|
||||
throw new Error(`agent "${agent.id}" has no model: set AgentOptions.model or supply one via the agent/request waterfall`)
|
||||
}
|
||||
|
||||
// The session prefix was composed (once per instance) before this step's
|
||||
// pre-step seam — the caller guarantees it, so the cache is always set here.
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- runTurn composes the prefix before every runStep call
|
||||
const sessionPrefix = transmission.sessionPrefix!
|
||||
|
||||
// The request header (the log's request/header* vocabulary): canonical form,
|
||||
// recorded before dispatch so the log always explains the request.
|
||||
// recorded before dispatch so the log always explains the request —
|
||||
// including the session prefix, which no other event carries.
|
||||
const header = canonicalHeader({
|
||||
config,
|
||||
...system ? { system } : {},
|
||||
...assembly.tools.length > 0 ? { tools: assembly.tools } : {},
|
||||
...sessionPrefix.length > 0 ? { messagePrefix: sessionPrefix } : {},
|
||||
})
|
||||
recordRequestHeader(session, transmission, header)
|
||||
|
||||
// Build and freeze: the request is a pure function of (boundary snapshot,
|
||||
// logged header) — llm/stream listeners and adapters read it, mutation
|
||||
// throws. sessionId + frozen is the loop-built marker the dev invariant
|
||||
// keys on.
|
||||
// keys on. Message order: header.messagePrefix, then the boundary
|
||||
// snapshot — the reconstruction equation the invariant recomputes.
|
||||
const request: GenerateOptions = deepFreeze({
|
||||
model: header.config.model,
|
||||
messages: boundaryMessages,
|
||||
messages: [...header.messagePrefix ?? [], ...boundaryMessages],
|
||||
...header.system !== undefined ? { system: header.system } : {},
|
||||
...header.tools !== undefined ? { tools: header.tools } : {},
|
||||
...header.config.temperature !== undefined ? { temperature: header.config.temperature } : {},
|
||||
@@ -875,7 +937,11 @@ function withoutToolCalls(message: Message): Message {
|
||||
return { ...message, content: message.content.filter(block => block.type !== 'tool-call') }
|
||||
}
|
||||
|
||||
/** The last turn number in a (possibly seeded) session log, or 0. */
|
||||
/**
|
||||
* The last turn number in a (possibly seeded) session log, or 0.
|
||||
* @param session - the session whose log is scanned for the latest `turn/start`.
|
||||
* @returns the latest `turn/start`'s turn number, or 0 when the log has none (the next turn is this plus one).
|
||||
*/
|
||||
export function lastTurnNumber(session: Session): number {
|
||||
const lastStart = session.events.findLast(event => event.type === 'turn/start')
|
||||
return lastStart?.data.turn ?? 0
|
||||
@@ -889,6 +955,8 @@ export function lastTurnNumber(session: Session): number {
|
||||
* returns to idle), so status is not a reliable open-turn signal. Used by
|
||||
* `inject()` to choose between appending into an open turn vs. wrapping the
|
||||
* injection in its own one-shot turn (the turn-enclosure RFC).
|
||||
* @param session - the session whose log is inspected.
|
||||
* @returns true when the log's last turn boundary is a `turn/start` with no matching `turn/end` yet.
|
||||
*/
|
||||
export function isTurnOpen(session: Session): boolean {
|
||||
const last = session.events.findLast(e => e.type === 'turn/start' || e.type === 'turn/end')
|
||||
|
||||
@@ -12,14 +12,26 @@
|
||||
|
||||
import { diffHeader, headerEquals, applyHeaderDelta } from '@deepseek-ai/dsh-session'
|
||||
import type { EpochHeader, Session } from '@deepseek-ai/dsh-session'
|
||||
import type { Message } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
/** Per-loop-instance bookkeeping: whether THIS instance has logged a header yet. */
|
||||
export interface TransmissionLog {
|
||||
/** True once this loop instance appended its anchoring `request/header` snapshot. */
|
||||
loggedHeader: boolean
|
||||
/**
|
||||
* The instance's composed session prefix (the `agent/session-prefix`
|
||||
* waterfall's deep-frozen product), cached on the instance's first
|
||||
* request-building step and reused verbatim for every request it sends —
|
||||
* the structural guarantee that the prefix never changes mid-session.
|
||||
* `undefined` until composed.
|
||||
*/
|
||||
sessionPrefix?: Message[]
|
||||
}
|
||||
|
||||
/** Fresh bookkeeping for a newly-started loop instance. */
|
||||
/**
|
||||
* Fresh bookkeeping for a newly-started loop instance.
|
||||
* @returns state with `loggedHeader` false, so the instance's first request appends an anchoring snapshot.
|
||||
*/
|
||||
export function createTransmissionLog(): TransmissionLog {
|
||||
return { loggedHeader: false }
|
||||
}
|
||||
@@ -58,7 +70,7 @@ export function recordRequestHeader(session: Session, state: TransmissionLog, he
|
||||
const baseline = session.requestHeader()!
|
||||
if (headerEquals(baseline, header)) return
|
||||
const delta = diffHeader(baseline, header)
|
||||
/* v8 ignore next -- headerEquals false ⟹ diffHeader defined: both compare the same three parts */
|
||||
/* v8 ignore next -- headerEquals false ⟹ diffHeader defined: both compare the same four parts */
|
||||
if (delta === undefined) return
|
||||
if (headerEquals(applyHeaderDelta(baseline, delta), header)) {
|
||||
session.append('request/header-delta', delta)
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import LlmService, { type Message } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
@@ -166,6 +166,103 @@ describe('Agent.cancel()', () => {
|
||||
expect(reasons.length).toBe(2)
|
||||
})
|
||||
|
||||
it('cancel from inside the agent/session-prefix waterfall drops the step (prefix-composition window)', async () => {
|
||||
const adapter = new MockAdapter([textResponse('should not stream')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
// Prefix composition runs before the pre-step seam on the instance's first
|
||||
// step; a cancel landing inside it must drop the about-to-start step
|
||||
// without running the seam or the model.
|
||||
let streamed = false
|
||||
ctx.on('session/event', (_s, event) => { if (event.type === 'assistant/chunk') streamed = true })
|
||||
ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next) => {
|
||||
agent.cancel('from prefix composition')
|
||||
return next()
|
||||
})
|
||||
|
||||
const reasons: TurnEndReason[] = []
|
||||
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(streamed).toBe(false)
|
||||
expect(reasons).toEqual([{ kind: 'aborted', reason: 'from prefix composition' }])
|
||||
})
|
||||
|
||||
it('disposal from inside the agent/session-prefix waterfall ends the turn disposed (prefix-composition window)', async () => {
|
||||
const adapter = new MockAdapter([textResponse('should not stream')])
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
|
||||
const handle = ctx.agents.create({
|
||||
agentId: AgentId('a-dispose-prefix'),
|
||||
sessionId: SessionId('dispose-prefix-session'),
|
||||
agentOptions: { model: 'mock' },
|
||||
})
|
||||
const agent = handle.agent as ReactLoopAgent
|
||||
|
||||
let disposalDone: Promise<void> | undefined
|
||||
let streamed = false
|
||||
ctx.on('session/event', (_s, event) => { if (event.type === 'assistant/chunk') streamed = true })
|
||||
ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next) => {
|
||||
disposalDone = handle.dispose()
|
||||
return next()
|
||||
})
|
||||
|
||||
send(agent, 'go')
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
await disposalDone
|
||||
await agent.done
|
||||
|
||||
// No step opened, no model call ran, and the turn closed disposed.
|
||||
expect(streamed).toBe(false)
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
const turnEnd = agent.session.events.findLast(e => e.type === 'turn/end')
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'disposed' })
|
||||
})
|
||||
|
||||
it('a cancel-interrupted prefix composition is discarded: the next send recomposes and ships the fresh prefix (stale-cache guard)', async () => {
|
||||
const adapter = new MockAdapter([textResponse('reply')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
// The first composition is interrupted mid-waterfall and — like an
|
||||
// abort-aware listener bailing on a firing signal — contributes nothing.
|
||||
// Caching that degraded result would silently strip the prefix from every
|
||||
// later request of this instance; the loop must discard it and recompose
|
||||
// on the next send, and the SECOND composition's value must be what the
|
||||
// wire and the header log carry.
|
||||
const opener: Message = { role: 'user', content: [{ type: 'text', text: 'fresh opener' }] }
|
||||
let compositions = 0
|
||||
ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next): Promise<Message[]> => {
|
||||
compositions += 1
|
||||
if (compositions === 1) {
|
||||
agent.cancel('mid-composition')
|
||||
return next()
|
||||
}
|
||||
return [opener, ...await next()]
|
||||
})
|
||||
|
||||
send(agent, 'dropped')
|
||||
await waitForIdle(ctx, agent)
|
||||
send(agent, 'real prompt')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(compositions).toBe(2)
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
expect(adapter.requests[0]?.messages[0]).toEqual(opener)
|
||||
const headerEvent = agent.session.events.find(e => e.type === 'request/header')
|
||||
expect(headerEvent?.type === 'request/header' && headerEvent.data.header.messagePrefix).toEqual([opener])
|
||||
})
|
||||
|
||||
it('cancel from a synchronous turn/start session-event listener drops the step (step-start window)', async () => {
|
||||
const adapter = new MockAdapter([textResponse('should not stream')])
|
||||
const ctx = await harness(adapter)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService, { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import LlmService, { CallId, type Message } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { defineTool, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools'
|
||||
@@ -310,6 +310,162 @@ describe('agent/session-start', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('agent/session-prefix', () => {
|
||||
it('composes once per loop instance and fronts every request; the header records it; history stays untouched', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
toolCallResponse('c1', 'echo', { text: 'ping' }),
|
||||
textResponse('done'),
|
||||
textResponse('again'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'echo', description: 'echo', parameters: { text: { type: 'string' } },
|
||||
async execute(args) { return [{ type: 'text', text: String(args.text) }] },
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
const reminder: Message = { role: 'user', content: [{ type: 'text', text: '<system-reminder>catalog</system-reminder>' }] }
|
||||
let composed = 0
|
||||
ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next): Promise<Message[]> => {
|
||||
composed += 1
|
||||
return [...await next(), reminder]
|
||||
})
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
send(agent, 'next turn')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
// Three requests (two turns), ONE composition: the frozen product is
|
||||
// reused verbatim, so the prefix cannot drift mid-session.
|
||||
expect(adapter.requests).toHaveLength(3)
|
||||
expect(composed).toBe(1)
|
||||
for (const request of adapter.requests) {
|
||||
expect(request.messages[0]).toEqual(reminder)
|
||||
}
|
||||
// The anchoring snapshot is the prefix's durable record — and the ONLY
|
||||
// header event: reuse means no request/header-delta ever.
|
||||
const headerEvents = events(agent).filter(e => e.type === 'request/header' || e.type === 'request/header-delta')
|
||||
expect(headerEvents).toHaveLength(1)
|
||||
expect(headerEvents[0]?.type === 'request/header' && headerEvents[0].data.header.messagePrefix).toEqual([reminder])
|
||||
// Never session history: the derivation starts at the real user prompt.
|
||||
expect(agent.session.deriveMessages()[0]).toEqual({ role: 'user', content: [{ type: 'text', text: 'go' }] })
|
||||
})
|
||||
|
||||
it('composes before the first pre-step and hands the prefix to the seam (pressure gates see the real value)', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
const reminder: Message = { role: 'user', content: [{ type: 'text', text: 'opener' }] }
|
||||
const order: string[] = []
|
||||
ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next): Promise<Message[]> => {
|
||||
order.push('compose')
|
||||
return [reminder, ...await next()]
|
||||
})
|
||||
const seen: (readonly Message[])[] = []
|
||||
ctx.on('agent/pre-step', (_agent, _turn, _step, _system, sessionPrefix) => {
|
||||
order.push('pre-step')
|
||||
seen.push(sessionPrefix)
|
||||
})
|
||||
|
||||
send(agent, 'hi')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
// Composition precedes the pre-step seam, and the seam receives THIS
|
||||
// instance's composed prefix — a token-pressure gate (compaction) counts
|
||||
// what the request will actually carry, never a stale logged prefix.
|
||||
expect(order).toEqual(['compose', 'pre-step'])
|
||||
expect(seen[0]).toEqual([reminder])
|
||||
})
|
||||
|
||||
it('the canonical prepend pattern composes contributions in registration order', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
// Both listeners use the canonical `[mine, ...await next()]` prepend: the
|
||||
// waterfall unwinds innermost-first (the second listener's array is built
|
||||
// first), so prepending puts the FIRST-registered contribution first.
|
||||
ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next): Promise<Message[]> => {
|
||||
return [{ role: 'user', content: [{ type: 'text', text: 'first' }] }, ...await next()]
|
||||
})
|
||||
ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next): Promise<Message[]> => {
|
||||
return [{ role: 'user', content: [{ type: 'text', text: 'second' }] }, ...await next()]
|
||||
})
|
||||
|
||||
send(agent, 'hi')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const texts = adapter.requests[0]!.messages.map(m => m.content[0]?.type === 'text' ? m.content[0].text : '')
|
||||
expect(texts).toEqual(['first', 'second', 'hi'])
|
||||
})
|
||||
|
||||
it('with no contributions the header omits messagePrefix and the request is the bare derivation', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
// A listener that delegates without contributing — the canonical no-op.
|
||||
ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next) => next())
|
||||
|
||||
send(agent, 'hi')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const headerEvent = events(agent).find(e => e.type === 'request/header')
|
||||
expect(headerEvent?.type === 'request/header' && 'messagePrefix' in headerEvent.data.header).toBe(false)
|
||||
expect(adapter.requests[0]!.messages).toEqual([{ role: 'user', content: [{ type: 'text', text: 'hi' }] }])
|
||||
})
|
||||
|
||||
it('the frozen seed rejects in-place mutation — a contribution is a returned extension', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
let mutationError: unknown
|
||||
ctx.on('agent/session-prefix', async (_agent, prefix, _signal, next): Promise<Message[]> => {
|
||||
try {
|
||||
prefix.push({ role: 'user', content: [{ type: 'text', text: 'smuggled' }] })
|
||||
} catch (error: unknown) {
|
||||
mutationError = error
|
||||
}
|
||||
return next()
|
||||
})
|
||||
|
||||
send(agent, 'hi')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(mutationError).toBeInstanceOf(TypeError)
|
||||
expect(adapter.requests[0]!.messages).toEqual([{ role: 'user', content: [{ type: 'text', text: 'hi' }] }])
|
||||
})
|
||||
|
||||
it('mutating a listener-held reference after composition cannot alter later requests (the cache is a frozen clone)', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
toolCallResponse('c1', 'echo', { text: 'ping' }),
|
||||
textResponse('done'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'echo', description: 'echo', parameters: { text: { type: 'string' } },
|
||||
async execute(args) { return [{ type: 'text', text: String(args.text) }] },
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
const held: Message = { role: 'user', content: [{ type: 'text', text: 'v1' }] }
|
||||
ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next): Promise<Message[]> => [...await next(), held])
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
// The listener mutates the object it contributed AFTER composition; the
|
||||
// cached prefix is a deep-frozen clone, so step 2's request is unchanged.
|
||||
held.content = [{ type: 'text', text: 'v2' }]
|
||||
expect(adapter.requests[1]!.messages[0]).toEqual({ role: 'user', content: [{ type: 'text', text: 'v1' }] })
|
||||
expect(events(agent).filter(e => e.type === 'request/header-delta')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
describe('agent/turn-continuation (ContinuationDecision)', () => {
|
||||
it('a continue decision with a reason records next-step steering in the same turn', async () => {
|
||||
const adapter = new MockAdapter([textResponse('step 1 no tools'), textResponse('step 2')])
|
||||
|
||||
117
packages/core/agent-loop/tests/tool-order.spec.ts
Normal file
117
packages/core/agent-loop/tests/tool-order.spec.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Loop-level tool-order determinism: the request/header event — and therefore
|
||||
* the frozen request the adapter receives — carries the assembly's canonical
|
||||
* tool order (system-prompt's `toolOrder` config, or lexicographic name
|
||||
* order), regardless of the order tool plugins happened to register in.
|
||||
* Registration order is a plugin-load artifact (concurrent dynamic imports
|
||||
* race), so nothing downstream of the registry may depend on it.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { foldRequestHeader } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt, { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
|
||||
import type { Config as SystemPromptConfig } from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||
import { MockAdapter, textResponse } from './mock-adapter.ts'
|
||||
|
||||
async function harness(adapter: MockAdapter, toolOrder?: SystemPromptConfig['toolOrder']) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt, { persona: 'stable base', ...toolOrder !== undefined ? { toolOrder } : {} })
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
return ctx
|
||||
}
|
||||
|
||||
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const dispose = ctx.on('agent/status', (subject, status) => {
|
||||
if (subject === agent && status === 'idle') {
|
||||
dispose()
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function registerNamed(ctx: Context, name: string) {
|
||||
ctx.tools.register(defineTool({
|
||||
name,
|
||||
description: `the ${name} tool`,
|
||||
parameters: {},
|
||||
async execute() {
|
||||
return [{ type: 'text', text: name }]
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
/** Run one text-only turn and return the harness context + agent. */
|
||||
async function runTurn(registrationOrder: string[], toolOrder?: SystemPromptConfig['toolOrder']) {
|
||||
const adapter = new MockAdapter([textResponse('done')])
|
||||
const ctx = await harness(adapter, toolOrder)
|
||||
for (const name of registrationOrder) registerNamed(ctx, name)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
return { ctx, agent, adapter }
|
||||
}
|
||||
|
||||
describe('loop-level canonical tool order', () => {
|
||||
it('logs the request/header with tools in canonical order, not registration order', async () => {
|
||||
const { agent, adapter } = await runTurn(['zulu', 'alpha', 'mike'])
|
||||
const header = foldRequestHeader(agent.session.events)
|
||||
expect(header?.tools?.map(tool => tool.name)).toEqual(['alpha', 'mike', 'zulu'])
|
||||
// The dispatched request is built FROM the logged header (whose tools the
|
||||
// assembly already canonicalized) and reaches the adapter deep-frozen —
|
||||
// the marker the reconstruction invariant keys on.
|
||||
expect(adapter.requests[0]?.tools?.map(tool => tool.name)).toEqual(['alpha', 'mike', 'zulu'])
|
||||
expect(Object.isFrozen(adapter.requests[0])).toBe(true)
|
||||
expect(adapter.requests[0]?.sessionId).toBe(agent.session.id)
|
||||
})
|
||||
|
||||
it('produces the same header order for any registration order', async () => {
|
||||
const first = await runTurn(['alpha', 'mike', 'zulu'])
|
||||
const second = await runTurn(['zulu', 'mike', 'alpha'])
|
||||
const names = (run: typeof first) => foldRequestHeader(run.agent.session.events)?.tools?.map(tool => tool.name)
|
||||
expect(names(first)).toEqual(['alpha', 'mike', 'zulu'])
|
||||
expect(names(second)).toEqual(names(first))
|
||||
})
|
||||
|
||||
it('honors a configured toolOrder in the logged header and the dispatched request', async () => {
|
||||
const { agent, adapter } = await runTurn(['alpha', 'zulu', 'mike'], ['zulu', TOOL_ORDER_REST])
|
||||
const header = foldRequestHeader(agent.session.events)
|
||||
expect(header?.tools?.map(tool => tool.name)).toEqual(['zulu', 'alpha', 'mike'])
|
||||
expect(adapter.requests[0]?.tools?.map(tool => tool.name)).toEqual(['zulu', 'alpha', 'mike'])
|
||||
expect(Object.isFrozen(adapter.requests[0])).toBe(true)
|
||||
})
|
||||
|
||||
it('fails the turn — no model request — when toolOrder names an unregistered tool', async () => {
|
||||
// The assemble rejection escapes to runTurn's outer catch: the open turn
|
||||
// closes with an `error` reason (agent/error mirrors it), no step opens,
|
||||
// no request/header is logged, the adapter never sees a request, and the
|
||||
// agent returns to idle — a misconfigured deployment fails every turn
|
||||
// deterministically instead of silently reordering nothing.
|
||||
const adapter = new MockAdapter([textResponse('never sent')])
|
||||
const ctx = await harness(adapter, ['ghost', TOOL_ORDER_REST])
|
||||
registerNamed(ctx, 'alpha')
|
||||
const errors: Error[] = []
|
||||
ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error))
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
expect(errors.map(e => e.message)).toEqual(['toolOrder lists unregistered tool "ghost"; registered tools: alpha'])
|
||||
expect(foldRequestHeader(agent.session.events)).toBeUndefined()
|
||||
const end = agent.session.events.find(e => e.type === 'turn/end')
|
||||
expect(end?.type === 'turn/end' && end.data.reason).toMatchObject({ kind: 'error', step: 1 })
|
||||
// The turn is balanced (turn/start → turn/end) with no step events inside.
|
||||
expect(agent.session.events.some(e => e.type === 'step/start')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -43,8 +43,9 @@ Turn and step boundaries are NOT mirrored as `agent/*` emits: a consumer that ne
|
||||
|
||||
- `agent/session-start` (emit) — fired once before the first turn; a listener seeds context via `agent.inject()` (it cannot veto startup).
|
||||
- `agent/prompt-submit` — decide what happens to one drained queued message before it becomes a `user/message`: `PromptDecision` = `allow` (optionally rewriting the prompt `content` or attaching `additionalContext`) or `block` (drop it; a batch whose every prompt is blocked opens a zero-step turn that ends `rejected`). Maps onto Claude Code's `UserPromptSubmit`.
|
||||
- `agent/pre-step` (serial) — mutate the session surface before the step opens and history is derived (compaction). Fires after `turn/start` and before `step/start`, so a listener's appended events land outside the step.
|
||||
- `agent/pre-step` (serial) — mutate the session surface before the step opens and history is derived (compaction). Fires after `turn/start` and before `step/start`, so a listener's appended events land outside the step; carries the assembled system prompt and the instance's composed session prefix so a token-pressure gate counts everything the request will carry.
|
||||
- `agent/request` — shape the call config before the model call: a frozen `LlmCallConfig` seed in, a replacement out (model switching, sampling overrides). Content is not shapeable here — every request is a pure function of the session log ([reconstructability RFC](../../../docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md)); the loop logs whatever config the request actually uses as a `request/header*` event
|
||||
- `agent/session-prefix` — compose the session prefix: request-only messages placed in front of the ENTIRE derived history on every request. Fired ONCE per loop instance, lazily before its first pre-step (so pressure gates see this instance's real prefix, never a previous instance's logged one); the composed result is deep-frozen, recorded as `EpochHeader.messagePrefix` on the anchoring `request/header` snapshot, and reused verbatim afterwards — the prefix cannot change mid-session, so the provider prefix cache holds by construction (resume = a new instance = a recompose, attributably anchored by its `'resume'` snapshot). The home for session-stable openers that must not become durable history (a skills catalog, an AGENTS.md digest); `deriveMessages()` never returns it. Content that CHANGES mid-session belongs in the append-only history channels instead — `agent.inject()`, `tools/post-execute` `additionalContext`, prompt-submit `additionalContext` — each a durable `context/message` paid once and prefix-cached thereafter
|
||||
- `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 via `ContinuationDecision` = `{action:'stop'}` or `{action:'continue', reason?}` (a `continue` `reason` is recorded as next-step steering in the same turn — the typed `/goal` pattern). Force-continue `/loop`, force-stop budget guard.
|
||||
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
* consumer that wants the live transcript subscribes here.
|
||||
* - **`agent/*`** (this module) — the LIVE runtime surface. Always carries the
|
||||
* live `Agent`. Two shapes: INTERCEPTION seams (the `agent/prompt-submit`/
|
||||
* `agent/request`/`agent/step-result`/`agent/turn-continuation` waterfalls and
|
||||
* `agent/request`/`agent/session-prefix`/`agent/step-result`/
|
||||
* `agent/turn-continuation` waterfalls and
|
||||
* the serial `agent/pre-step`) that mutate/veto, and TRANSIENT emits
|
||||
* (`agent/status`, `agent/error`, `agent/created`/
|
||||
* `agent/disposed`, `agent/queued`, `agent/session-start`)
|
||||
@@ -50,7 +51,11 @@ import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
/** Identifies one live agent in the registry. */
|
||||
export type AgentId = Branded<'AgentId'>
|
||||
|
||||
/** Brand a string as an {@link AgentId}. */
|
||||
/**
|
||||
* Brand a string as an {@link AgentId}.
|
||||
* @param id - the raw agent id string.
|
||||
* @returns the same string, branded (a compile-time cast — no runtime cost).
|
||||
*/
|
||||
export function AgentId(id: string): AgentId {
|
||||
return id as AgentId
|
||||
}
|
||||
@@ -80,10 +85,22 @@ export interface AgentOptions {
|
||||
model?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for {@link Agent.send}/{@link Agent.steer}/{@link Agent.inject}. An
|
||||
* absent `source` resolves to `{ kind: 'user' }`, so a plugin supplying content
|
||||
* must label itself here or its message is recorded as a user prompt (see
|
||||
* {@link HookContext} on why that label is load-bearing).
|
||||
*/
|
||||
export interface SendOptions {
|
||||
source?: MessageSource
|
||||
}
|
||||
|
||||
/**
|
||||
* An agent's lifecycle state, emitted on every transition as `agent/status`:
|
||||
* `idle` (parked, waiting for queued work), `running` (a turn is in progress),
|
||||
* `disposed` (terminal — no transition leaves it, and `send`/`steer`/`inject`
|
||||
* throw).
|
||||
*/
|
||||
export type AgentStatus = 'idle' | 'running' | 'disposed'
|
||||
|
||||
/**
|
||||
@@ -316,21 +333,28 @@ declare module 'cordis' {
|
||||
* value; this event is typed and documented as `void`, so listeners must not
|
||||
* return a semantic veto value. `fullSystemPrompt` is the assembled prompt a
|
||||
* listener needs to measure pressure (the system prompt counts toward the
|
||||
* budget). `signal` cancels any in-flight work a listener starts (e.g. a
|
||||
* budget), and `sessionPrefix` is the instance's composed
|
||||
* {@link agent/session-prefix} product for the same reason — every request
|
||||
* carries it in front of the derived history, and it is composed BEFORE
|
||||
* this seam fires precisely so a pressure gate counts the prefix the
|
||||
* request will actually send (never a stale logged one). `signal` cancels
|
||||
* any in-flight work a listener starts (e.g. a
|
||||
* summarization model call).
|
||||
* @param agent - the agent about to open the step.
|
||||
* @param turn - the already-open turn this step belongs to.
|
||||
* @param step - the number of the step about to start.
|
||||
* @param fullSystemPrompt - the assembled prompt, for measuring token pressure.
|
||||
* @param sessionPrefix - the instance's frozen session prefix, for the same measurement.
|
||||
* @param signal - aborts in-flight listener work when the turn is torn down.
|
||||
* @mode serial
|
||||
*/
|
||||
// TODO: `fullSystemPrompt` is a smell on a generic per-step seam — compaction
|
||||
// is its only consumer, so a wide event carries a string just one listener
|
||||
// TODO: `fullSystemPrompt`/`sessionPrefix` are a smell on a generic
|
||||
// per-step seam — compaction
|
||||
// is their only consumer, so a wide event carries payloads just one listener
|
||||
// reads. Revisit if no second consumer appears: e.g. hand listeners a lazy
|
||||
// prompt provider, or move token-pressure measurement behind a
|
||||
// compaction-specific seam instead of the shared pre-step checkpoint.
|
||||
'agent/pre-step'(agent: Agent, turn: number, step: number, fullSystemPrompt: string, signal: AbortSignal): Promise<void> | void
|
||||
'agent/pre-step'(agent: Agent, turn: number, step: number, fullSystemPrompt: string, sessionPrefix: readonly Message[], signal: AbortSignal): Promise<void> | void
|
||||
/**
|
||||
* Waterfall: decide what happens to ONE drained queued message before it
|
||||
* becomes a `user/message` — allow (optionally rewriting the prompt bytes or
|
||||
@@ -351,8 +375,9 @@ declare module 'cordis' {
|
||||
* ALL a listener shapes here: every request is a pure function of the
|
||||
* session log (the reconstructability RFC), so model-visible content
|
||||
* flows through the log channels — `inject()`, steering, prompt-submit
|
||||
* `additionalContext`, prompt sections via `system-prompt/assemble` —
|
||||
* never through request mutation, and the loop records whatever config
|
||||
* `additionalContext`, prompt sections via `system-prompt/assemble`, or
|
||||
* the header-logged session prefix via {@link agent/session-prefix}
|
||||
* — never through request mutation, and the loop records whatever config
|
||||
* the request actually uses as a `request/header*` event before dispatch.
|
||||
* The step's messages are already snapshotted when this fires (the
|
||||
* `step/start` boundary): an `inject()` from a listener here lands in the
|
||||
@@ -367,6 +392,53 @@ declare module 'cordis' {
|
||||
* @mode waterfall
|
||||
*/
|
||||
'agent/request'(agent: Agent, turn: number, step: number, config: LlmCallConfig, next: () => Promise<LlmCallConfig>): Promise<LlmCallConfig>
|
||||
/**
|
||||
* Waterfall: compose the SESSION PREFIX — request-only messages placed in
|
||||
* front of the ENTIRE derived history (directly after the provider's
|
||||
* system slot) on every request this loop instance sends. Fired ONCE per
|
||||
* loop instance, lazily before its first step's {@link agent/pre-step}
|
||||
* seam — BEFORE the pre-step so a token-pressure gate (compaction) counts
|
||||
* the prefix this instance will actually send, never a previous
|
||||
* instance's logged one. The composed
|
||||
* result is deep-frozen, recorded as `EpochHeader.messagePrefix` on the
|
||||
* instance's anchoring `'initial'`/`'resume'` header snapshot, and reused
|
||||
* verbatim for every subsequent request — never recomputed mid-session,
|
||||
* so the provider prefix cache holds by construction (a process restart
|
||||
* or `ctx.agents.resume()` is a new instance: it recomposes, and any
|
||||
* drift lands attributably on the `'resume'` snapshot). Composition runs
|
||||
* outside the step, before the boundary snapshot: a composing listener's
|
||||
* session append joins the CURRENT request's derived history. A
|
||||
* composition interrupted by a cancel/dispose landing inside the
|
||||
* waterfall is discarded — never cached, logged, or sent — and the next
|
||||
* turn recomposes under a live signal, so an abort-aware listener's
|
||||
* degraded fallback cannot leak into later requests.
|
||||
*
|
||||
* This is the home for session-stable openers the model must always see
|
||||
* but that must NOT become durable history — a skills catalog, an
|
||||
* AGENTS.md digest, a workspace baseline: `Session.deriveMessages()`
|
||||
* never returns the prefix, and the header events are its only durable
|
||||
* record, so the request stays reconstructable from the log. Content
|
||||
* that CHANGES mid-session belongs in the append-only history channels
|
||||
* instead — `agent.inject()`, a `tools/post-execute` decision's
|
||||
* `additionalContext`, prompt-submit `additionalContext` — each a
|
||||
* durable `context/message` paid once and prefix-cached thereafter.
|
||||
*
|
||||
* The seed is a frozen empty list; a contributing listener returns a NEW
|
||||
* array — never an in-place push. The canonical contribution is a
|
||||
* PREPEND, `[mine, ...await next()]`: the waterfall unwinds
|
||||
* innermost-first (the LAST-registered listener's `next()` resolves
|
||||
* first), so prepending yields registration order on the wire, and every
|
||||
* plugin using it composes deterministically. The append form
|
||||
* `[...await next(), mine]` is legal but places a contribution AFTER
|
||||
* every later-registered plugin's — reverse registration order when all
|
||||
* contributors append. Call `next()` to
|
||||
* delegate, or return a list without it to short-circuit.
|
||||
* @param agent - the agent whose session prefix is being composed.
|
||||
* @param prefix - the frozen empty seed; return an extended replacement to contribute.
|
||||
* @param signal - aborts in-flight listener work (e.g. a discovery scan) when the step is torn down.
|
||||
* @mode waterfall
|
||||
*/
|
||||
'agent/session-prefix'(agent: Agent, prefix: Message[], signal: AbortSignal, next: () => Promise<Message[]>): Promise<Message[]>
|
||||
/**
|
||||
* Waterfall: post-process the assembled assistant {@link Message} before
|
||||
* tool dispatch (validation, content rewriting, …).
|
||||
|
||||
555
packages/core/agent/tests/verify-export-jsdoc.spec.ts
Normal file
555
packages/core/agent/tests/verify-export-jsdoc.spec.ts
Normal file
@@ -0,0 +1,555 @@
|
||||
/**
|
||||
* Negative-path tests for the export-surface JSDoc gate
|
||||
* (`scripts/verify-export-jsdoc.ts`).
|
||||
*
|
||||
* The gate's positive half runs against the real tree in CI (`pnpm run
|
||||
* verify-export-jsdoc`, part of doc-sync). What that run cannot prove is that
|
||||
* the walk REJECTS an undocumented surface the way it promises to — and that
|
||||
* every deliberate exemption (heritage members, plugin-protocol slots,
|
||||
* constructors, overload implementations, augmentation bodies, re-exports)
|
||||
* actually holds. These tests drive `collectExportJsdocViolations()` against
|
||||
* synthetic fixture packages, mirroring the gen-cordis-catalog negative
|
||||
* tests.
|
||||
*/
|
||||
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { collectExportJsdocViolations } from '../../../../scripts/verify-export-jsdoc.ts'
|
||||
|
||||
const roots: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
/** Write fixture files under `packages/group/fix/src/` and return the scan root. */
|
||||
function fixture(files: Record<string, string>): string {
|
||||
const root = mkdtempSync(join(tmpdir(), 'export-jsdoc-'))
|
||||
roots.push(root)
|
||||
for (const [rel, content] of Object.entries(files)) {
|
||||
const abs = join(root, 'packages', 'group', 'fix', 'src', rel)
|
||||
mkdirSync(dirname(abs), { recursive: true })
|
||||
writeFileSync(abs, content)
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
/** Single-file fixture shorthand: the content becomes `src/index.ts`. */
|
||||
const make = (content: string): string => fixture({ 'index.ts': content })
|
||||
|
||||
describe('verify-export-jsdoc functions and consts', () => {
|
||||
it('accepts a fully documented surface', () => {
|
||||
expect(collectExportJsdocViolations(make(`
|
||||
/**
|
||||
* Add one to a count.
|
||||
* @param n - the count to bump.
|
||||
* @returns the count plus one.
|
||||
*/
|
||||
export function bump(n: number): number { return n + 1 }
|
||||
|
||||
/**
|
||||
* Fire-and-forget (void needs no @returns).
|
||||
* @param flag - whether to arm.
|
||||
*/
|
||||
export function poke(flag: boolean): void { void flag }
|
||||
|
||||
/** The default retry budget. */
|
||||
export const RETRIES = 3
|
||||
|
||||
/**
|
||||
* Halve a count.
|
||||
* @param n - the count to halve.
|
||||
* @returns the count halved.
|
||||
*/
|
||||
export const halve = (n: number): number => n / 2
|
||||
`))).toEqual([])
|
||||
})
|
||||
|
||||
it('flags an exported function with no JSDoc at all', () => {
|
||||
expect(collectExportJsdocViolations(make(
|
||||
'export function bare(): void {}\n',
|
||||
))).toEqual([expect.stringMatching(/exported function 'bare' .* has no JSDoc\./)])
|
||||
})
|
||||
|
||||
it('flags a missing @param and a missing @returns', () => {
|
||||
const violations = collectExportJsdocViolations(make(
|
||||
'/** Docs without tags. */\nexport function f(x: number): number { return x }\n',
|
||||
))
|
||||
expect(violations).toEqual([
|
||||
expect.stringMatching(/exported function 'f' .* is missing @param x\./),
|
||||
expect.stringMatching(/exported function 'f' .* is missing @returns \(return type: number\)\./),
|
||||
])
|
||||
})
|
||||
|
||||
it('flags an unannotated (inferred) return type', () => {
|
||||
expect(collectExportJsdocViolations(make(
|
||||
'/**\n * Docs.\n * @param x - value.\n */\nexport function f(x: number) { return x }\n',
|
||||
))).toEqual([expect.stringMatching(/no return type annotation/)])
|
||||
})
|
||||
|
||||
it('flags tags-only JSDoc with no description prose', () => {
|
||||
expect(collectExportJsdocViolations(make(
|
||||
'/**\n * @param x - value.\n */\nexport function f(x: number): void {}\n',
|
||||
))).toEqual([expect.stringMatching(/no description prose above its block tags/)])
|
||||
})
|
||||
|
||||
it('flags a stale @param and a binding-pattern parameter', () => {
|
||||
const violations = collectExportJsdocViolations(make(
|
||||
'/**\n * Docs.\n * @param ghost - not real.\n */\nexport function f({ a }: { a: number }): void {}\n',
|
||||
))
|
||||
expect(violations).toEqual([
|
||||
expect.stringMatching(/parameter '\{ a \}' is a binding pattern; the export surface needs simple identifier parameters/),
|
||||
expect.stringMatching(/@param ghost does not match any parameter \(stale tag\?\)/),
|
||||
])
|
||||
})
|
||||
|
||||
it('exempts a `this` receiver annotation from @param', () => {
|
||||
expect(collectExportJsdocViolations(make(
|
||||
'/**\n * Docs.\n * @param x - value.\n */\nexport function f(this: object, x: number): void {}\n',
|
||||
))).toEqual([])
|
||||
})
|
||||
|
||||
it('waives @returns for a declarator-annotated const but not an unannotated one', () => {
|
||||
expect(collectExportJsdocViolations(make(`
|
||||
type Fn = (x: number) => number
|
||||
/**
|
||||
* Uses the named signature.
|
||||
* @param x - value.
|
||||
*/
|
||||
export const good: Fn = x => x
|
||||
/**
|
||||
* No signature anywhere.
|
||||
* @param x - value.
|
||||
*/
|
||||
export const bad = (x: number) => x
|
||||
`))).toEqual([expect.stringMatching(/exported const 'bad' .* has no return type annotation/)])
|
||||
})
|
||||
|
||||
it('requires description prose on a non-function const', () => {
|
||||
expect(collectExportJsdocViolations(make(
|
||||
'export const LIMIT = 10\n',
|
||||
))).toEqual([expect.stringMatching(/exported const 'LIMIT' .* has no JSDoc\./)])
|
||||
})
|
||||
})
|
||||
|
||||
describe('verify-export-jsdoc type-level exports', () => {
|
||||
it('requires description prose on interfaces, type aliases, and enums', () => {
|
||||
const violations = collectExportJsdocViolations(make(
|
||||
'export interface I { a: number }\nexport type T = number\nexport enum E { A }\n',
|
||||
))
|
||||
expect(violations).toEqual([
|
||||
expect.stringMatching(/exported interface 'I' .* has no JSDoc\./),
|
||||
expect.stringMatching(/exported type 'T' .* has no JSDoc\./),
|
||||
expect.stringMatching(/exported enum 'E' .* has no JSDoc\./),
|
||||
])
|
||||
})
|
||||
|
||||
it('skips `declare module` augmentation bodies (the cordis gate owns them)', () => {
|
||||
expect(collectExportJsdocViolations(make(
|
||||
"declare module 'cordis' {\n interface Events {\n 'fix/x'(): void\n }\n}\nexport {}\n",
|
||||
))).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('verify-export-jsdoc export forms', () => {
|
||||
it('resolves an `export { … }` list to the local declaration', () => {
|
||||
expect(collectExportJsdocViolations(make(
|
||||
'function f(): void {}\nexport { f }\n',
|
||||
))).toEqual([expect.stringMatching(/exported function 'f' .* has no JSDoc\./)])
|
||||
})
|
||||
|
||||
it('does not treat a never-exported sibling declarator as surface (review round 2)', () => {
|
||||
// `export { publicValue }` resolves to the whole variable statement; only
|
||||
// the named declarator is surface — the gate must not demand JSDoc for
|
||||
// the private sibling sharing the statement.
|
||||
expect(collectExportJsdocViolations(make(
|
||||
'/** The public knob. */\nconst publicValue = 1, privateHelper = 2\nexport { publicValue }\nvoid privateHelper\n',
|
||||
))).toEqual([])
|
||||
})
|
||||
|
||||
it('unions declarators across multiple export lists over one statement (review round 2)', () => {
|
||||
// Two lists each name one declarator of the same undocumented statement:
|
||||
// both are surface (deduplicating on first resolution would drop `b`),
|
||||
// while the never-exported `c` stays out.
|
||||
const violations = collectExportJsdocViolations(make(
|
||||
'const a = 1, b = 2, c = 3\nexport { a }\nexport { b }\nvoid c\n',
|
||||
))
|
||||
expect(violations).toEqual([
|
||||
expect.stringMatching(/exported const 'a' .* has no JSDoc\./),
|
||||
expect.stringMatching(/exported const 'b' .* has no JSDoc\./),
|
||||
])
|
||||
})
|
||||
|
||||
it('scopes a default-export identifier to its own declarator (review round 2)', () => {
|
||||
// `export default` of an identifier reaches the statement through the
|
||||
// same name lookup as an export list; the sibling stays private.
|
||||
expect(collectExportJsdocViolations(make(
|
||||
'/** The app entry. */\nconst app = 1, scratch = 2\nexport default app\nvoid scratch\n',
|
||||
))).toEqual([])
|
||||
})
|
||||
|
||||
it('reports a re-exported module once, at its defining file', () => {
|
||||
const violations = collectExportJsdocViolations(fixture({
|
||||
'index.ts': "export * from './other.ts'\n",
|
||||
'other.ts': 'export function f(): void {}\n',
|
||||
}))
|
||||
expect(violations).toEqual([expect.stringMatching(/other\.ts:1\) has no JSDoc\./)])
|
||||
})
|
||||
|
||||
it('exempts overload implementations when the signatures are documented', () => {
|
||||
expect(collectExportJsdocViolations(make(`
|
||||
/**
|
||||
* From a number.
|
||||
* @param x - the number.
|
||||
* @returns its text.
|
||||
*/
|
||||
export function f(x: number): string
|
||||
/**
|
||||
* From a flag.
|
||||
* @param x - the flag.
|
||||
* @returns its text.
|
||||
*/
|
||||
export function f(x: boolean): string
|
||||
export function f(x: number | boolean): string { return String(x) }
|
||||
`))).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('verify-export-jsdoc classes', () => {
|
||||
it('flags an undocumented class, method, property, and accessor', () => {
|
||||
const violations = collectExportJsdocViolations(make(`
|
||||
export class C {
|
||||
state = 1
|
||||
get view(): number { return this.state }
|
||||
run(x: number): number { return x }
|
||||
}
|
||||
`))
|
||||
expect(violations).toEqual([
|
||||
expect.stringMatching(/exported class 'C' .* has no JSDoc\./),
|
||||
expect.stringMatching(/exported class property 'C.state' .* has no JSDoc\./),
|
||||
expect.stringMatching(/exported class accessor 'C.view' .* has no JSDoc\./),
|
||||
expect.stringMatching(/exported class method 'C.run' .* has no JSDoc\./),
|
||||
])
|
||||
})
|
||||
|
||||
it('exempts members declared by an extends/implements heritage type', () => {
|
||||
expect(collectExportJsdocViolations(make(`
|
||||
/** Seam. */
|
||||
export abstract class Base {
|
||||
/**
|
||||
* Do it.
|
||||
* @param x - input.
|
||||
* @returns output.
|
||||
*/
|
||||
abstract run(x: number): number
|
||||
}
|
||||
/** Iface. */
|
||||
export interface Sized {
|
||||
/** Byte size. */
|
||||
size: number
|
||||
}
|
||||
/** Impl. */
|
||||
export class Impl extends Base implements Sized {
|
||||
size = 0
|
||||
run(x: number): number { return x }
|
||||
}
|
||||
`))).toEqual([])
|
||||
})
|
||||
|
||||
it('skips private/protected/#private members and constructors', () => {
|
||||
expect(collectExportJsdocViolations(make(`
|
||||
/** Documented. */
|
||||
export class C {
|
||||
#secret = 1
|
||||
private hidden(): void {}
|
||||
protected hook(): void {}
|
||||
constructor(x: number) { void x }
|
||||
}
|
||||
`))).toEqual([])
|
||||
})
|
||||
|
||||
it('exempts plugin-protocol statics but checks other statics', () => {
|
||||
const violations = collectExportJsdocViolations(make(`
|
||||
/** Plugin. */
|
||||
export class C {
|
||||
static Config = { a: 1 }
|
||||
static inject = ['bash']
|
||||
static reusable = true
|
||||
static other = 1
|
||||
}
|
||||
`))
|
||||
expect(violations).toEqual([expect.stringMatching(/exported class property 'C.other' .* has no JSDoc\./)])
|
||||
})
|
||||
|
||||
it("covers a set accessor by the getter's doc", () => {
|
||||
expect(collectExportJsdocViolations(make(`
|
||||
/** Documented. */
|
||||
export class C {
|
||||
/** The current width. */
|
||||
get width(): number { return 1 }
|
||||
set width(_v: number) {}
|
||||
}
|
||||
`))).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('verify-export-jsdoc plugin protocol and namespaces', () => {
|
||||
it('exempts top-level plugin-protocol exports', () => {
|
||||
expect(collectExportJsdocViolations(make(`
|
||||
export const name = 'fix'
|
||||
export const inject = ['bash']
|
||||
export const reusable = true
|
||||
export const Config = { parse: true }
|
||||
export function apply(): void {}
|
||||
`))).toEqual([])
|
||||
})
|
||||
|
||||
it('recurses into namespaces with qualified names and honors the merge idiom', () => {
|
||||
const violations = collectExportJsdocViolations(make(`
|
||||
/** The plugin class. */
|
||||
export class Fix {}
|
||||
export namespace Fix {
|
||||
export interface Config { a: number }
|
||||
}
|
||||
export namespace Loose {
|
||||
export const x = 1
|
||||
}
|
||||
`))
|
||||
expect(violations).toEqual([
|
||||
expect.stringMatching(/exported interface 'Fix.Config' .* has no JSDoc\./),
|
||||
expect.stringMatching(/exported namespace 'Loose' .* has no JSDoc\./),
|
||||
expect.stringMatching(/exported const 'Loose.x' .* has no JSDoc\./),
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('verify-export-jsdoc fail-closed forms (review round 1)', () => {
|
||||
it('checks the function contract on a non-identifier default export', () => {
|
||||
expect(collectExportJsdocViolations(make(
|
||||
'/** Doubles. */\nexport default (x: number): number => x * 2\n',
|
||||
))).toEqual([
|
||||
expect.stringMatching(/default export .* is missing @param x\./),
|
||||
expect.stringMatching(/default export .* is missing @returns \(return type: number\)\./),
|
||||
])
|
||||
expect(collectExportJsdocViolations(make(
|
||||
'/**\n * Doubles.\n * @param x - the input.\n * @returns twice the input.\n */\nexport default (x: number): number => x * 2\n',
|
||||
))).toEqual([])
|
||||
})
|
||||
|
||||
it('treats an inline function-type annotation as the surface signature', () => {
|
||||
expect(collectExportJsdocViolations(make(
|
||||
'/** Maps a number. */\nexport declare const f: (x: number) => number\n',
|
||||
))).toEqual([
|
||||
expect.stringMatching(/exported const 'f' .* is missing @param x\./),
|
||||
expect.stringMatching(/exported const 'f' .* is missing @returns \(return type: number\)\./),
|
||||
])
|
||||
expect(collectExportJsdocViolations(make(
|
||||
'/**\n * Maps a number.\n * @param x - the input.\n * @returns the mapped value.\n */\nexport const f: (x: number) => number = v => v\n',
|
||||
))).toEqual([])
|
||||
})
|
||||
|
||||
it('recurses into an ambient declare namespace where members export implicitly', () => {
|
||||
expect(collectExportJsdocViolations(make(
|
||||
'export declare namespace N {\n function f(x: number): number\n}\n',
|
||||
))).toEqual([
|
||||
expect.stringMatching(/exported namespace 'N' .* has no JSDoc\./),
|
||||
expect.stringMatching(/exported function 'N.f' .* has no JSDoc\./),
|
||||
])
|
||||
})
|
||||
|
||||
it('requires an export-import alias to document itself (its target may be unwalked)', () => {
|
||||
expect(collectExportJsdocViolations(make(
|
||||
'/** Holder. */\nexport namespace N {\n /** The value. */\n export const x = 1\n}\nexport import y = N.x\n',
|
||||
))).toEqual([expect.stringMatching(/exported alias 'y' .* has no JSDoc\./)])
|
||||
expect(collectExportJsdocViolations(make(
|
||||
'namespace N {\n export const x = 1\n}\n/** Alias surfacing the internal counter. */\nexport import y = N.x\n',
|
||||
))).toEqual([])
|
||||
})
|
||||
|
||||
it('refuses an export-import alias to a callable, class, or namespace target', () => {
|
||||
const refusal = /exported alias 'g' .* aliases a callable, class, or namespace target/
|
||||
expect(collectExportJsdocViolations(make(
|
||||
'namespace N {\n export function f(x: number): number { return x }\n}\n/** Alias. */\nexport import g = N.f\n',
|
||||
))).toEqual([expect.stringMatching(refusal)])
|
||||
expect(collectExportJsdocViolations(make(
|
||||
'namespace N {\n export class C {\n run(x: number): number { return x }\n }\n}\n/** Alias. */\nexport import g = N.C\n',
|
||||
))).toEqual([expect.stringMatching(refusal)])
|
||||
expect(collectExportJsdocViolations(make(
|
||||
'namespace N {\n export namespace Sub {\n export function f(x: number): number { return x }\n }\n}\n/** Alias. */\nexport import g = N.Sub\n',
|
||||
))).toEqual([expect.stringMatching(refusal)])
|
||||
})
|
||||
|
||||
it('classifies wrapped function initializers and default exports (parens, satisfies)', () => {
|
||||
expect(collectExportJsdocViolations(make(
|
||||
'type Fn = (x: number) => number\n/** Wrapped. */\nexport const f = (((x: number): number => x)) satisfies Fn\n',
|
||||
))).toEqual([
|
||||
expect.stringMatching(/exported const 'f' .* is missing @param x\./),
|
||||
expect.stringMatching(/exported const 'f' .* is missing @returns \(return type: number\)\./),
|
||||
])
|
||||
expect(collectExportJsdocViolations(make(
|
||||
'type Fn = (x: number) => number\n/** Wrapped. */\nexport default (((x: number): number => x * 2) satisfies Fn)\n',
|
||||
))).toEqual([
|
||||
expect.stringMatching(/default export .* is missing @param x\./),
|
||||
expect.stringMatching(/default export .* is missing @returns \(return type: number\)\./),
|
||||
])
|
||||
})
|
||||
|
||||
it('treats a single-call-signature type literal as the surface signature', () => {
|
||||
expect(collectExportJsdocViolations(make(
|
||||
'/** Maps. */\nexport declare const f: { (x: number): number }\n',
|
||||
))).toEqual([
|
||||
expect.stringMatching(/exported const 'f' .* is missing @param x\./),
|
||||
expect.stringMatching(/exported const 'f' .* is missing @returns \(return type: number\)\./),
|
||||
])
|
||||
})
|
||||
|
||||
it('refuses a hybrid callable type literal instead of narrowing the check', () => {
|
||||
expect(collectExportJsdocViolations(make(
|
||||
'/** Hybrid. */\nexport declare const f: { (x: number): number; flush: () => void }\n',
|
||||
))).toEqual([expect.stringMatching(/exported const 'f'.*callable type literal is not gate-classifiable; extract a named type/)])
|
||||
})
|
||||
|
||||
it('refuses an export-equals assignment instead of failing open', () => {
|
||||
expect(collectExportJsdocViolations(make(
|
||||
'const x = 1\nexport = x\n',
|
||||
))).toEqual([expect.stringMatching(/export-equals assignment .* is not a gate-supported export form/)])
|
||||
})
|
||||
})
|
||||
|
||||
describe('verify-export-jsdoc heritage refinement (review round 1)', () => {
|
||||
it('requires @param for parameters the base member never names', () => {
|
||||
const violations = collectExportJsdocViolations(make(`
|
||||
/** Seam. */
|
||||
export abstract class Base {
|
||||
/**
|
||||
* Do it.
|
||||
* @param x - input.
|
||||
* @returns output.
|
||||
*/
|
||||
abstract run(x: number): number
|
||||
}
|
||||
/** Impl. */
|
||||
export class Impl extends Base {
|
||||
override run(x: number, verbose?: boolean): number { return verbose ? x : -x }
|
||||
}
|
||||
`))
|
||||
expect(violations).toEqual([expect.stringMatching(/exported class method 'Impl.run' .* is missing @param verbose\./)])
|
||||
})
|
||||
|
||||
it('does not exempt a public override of a protected-only base member', () => {
|
||||
expect(collectExportJsdocViolations(make(`
|
||||
/** Seam. */
|
||||
export abstract class Base {
|
||||
/** Subclass hook. */
|
||||
protected hook(): void {}
|
||||
}
|
||||
/** Impl. */
|
||||
export class Impl extends Base {
|
||||
override hook(): void {}
|
||||
}
|
||||
`))).toEqual([expect.stringMatching(/exported class method 'Impl.hook' .* has no JSDoc\./)])
|
||||
})
|
||||
|
||||
it('treats an underscore-prefixed rename of a base parameter as the same parameter', () => {
|
||||
expect(collectExportJsdocViolations(make(`
|
||||
/** Seam. */
|
||||
export abstract class Base {
|
||||
/**
|
||||
* Load it.
|
||||
* @param cwd - the working directory to scope the lookup.
|
||||
* @returns the loaded value.
|
||||
*/
|
||||
abstract load(cwd: string): number
|
||||
}
|
||||
/** Impl (ignores cwd). */
|
||||
export class Impl extends Base {
|
||||
load(_cwd: string): number { return 1 }
|
||||
}
|
||||
`))).toEqual([])
|
||||
})
|
||||
|
||||
it('flags a binding-pattern parameter an override adds beyond the base', () => {
|
||||
expect(collectExportJsdocViolations(make(`
|
||||
/** Seam. */
|
||||
export abstract class Base {
|
||||
/**
|
||||
* Do it.
|
||||
* @param x - input.
|
||||
* @returns output.
|
||||
*/
|
||||
abstract run(x: number): number
|
||||
}
|
||||
/** Impl. */
|
||||
export class Impl extends Base {
|
||||
override run(x: number, { verbose }: { verbose?: boolean } = {}): number { return verbose ? x : -x }
|
||||
}
|
||||
`))).toEqual([expect.stringMatching(/exported class method 'Impl.run' .* is a binding pattern/)])
|
||||
})
|
||||
|
||||
it('revives the @returns duty when an override grows a concrete result over a void base', () => {
|
||||
const voidBase = `
|
||||
/** Seam. */
|
||||
export abstract class Base {
|
||||
/** Do it (fire-and-forget). */
|
||||
abstract run(): void
|
||||
}
|
||||
`
|
||||
expect(collectExportJsdocViolations(make(`${voidBase}
|
||||
/** Impl. */
|
||||
export class Impl extends Base {
|
||||
override run(): number { return 1 }
|
||||
}
|
||||
`))).toEqual([expect.stringMatching(/exported class method 'Impl.run' .* is missing @returns \(return type: number\)\./)])
|
||||
expect(collectExportJsdocViolations(make(`${voidBase}
|
||||
/** Impl. */
|
||||
export class Impl extends Base {
|
||||
/**
|
||||
* Do it and count.
|
||||
* @returns how many were done.
|
||||
*/
|
||||
override run(): number { return 1 }
|
||||
}
|
||||
`))).toEqual([])
|
||||
})
|
||||
|
||||
it('classifies an unannotated override return over a void base via the checker', () => {
|
||||
const voidBase = `
|
||||
/** Seam. */
|
||||
export abstract class Base {
|
||||
/** Do it (fire-and-forget). */
|
||||
abstract run(): void
|
||||
}
|
||||
`
|
||||
expect(collectExportJsdocViolations(make(`${voidBase}
|
||||
/** Impl. */
|
||||
export class Impl extends Base {
|
||||
override run() { return 1 }
|
||||
}
|
||||
`))).toEqual([expect.stringMatching(/exported class method 'Impl.run' .* non-void result its heritage declaration does not document/)])
|
||||
expect(collectExportJsdocViolations(make(`${voidBase}
|
||||
/** Impl (faithful void, no annotation needed). */
|
||||
export class Impl extends Base {
|
||||
override run() {}
|
||||
}
|
||||
`))).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps the full exemption when the base return already carries the @returns duty', () => {
|
||||
expect(collectExportJsdocViolations(make(`
|
||||
/** Seam. */
|
||||
export abstract class Base {
|
||||
/**
|
||||
* Count things.
|
||||
* @returns the count.
|
||||
*/
|
||||
abstract run(): number
|
||||
}
|
||||
/** Impl. */
|
||||
export class Impl extends Base {
|
||||
override run(): number { return 1 }
|
||||
}
|
||||
`))).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -9,6 +9,7 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall
|
||||
### Public API
|
||||
|
||||
- `ctx.sessions.create(id?: SessionId, options?: { seed?: SessionEvent[]; meta?: { cwd?: string; parentSession?: SessionId; createdAt?: number; seedLength?: number } }): Session` — Create a session. `options.seed` replays/forks an existing event log; `options.meta` attaches creation metadata (validated absolute `cwd`, `parentSession` lineage, seed boundary) as the immutable `SessionHeader`. The store fills `version`/`id` and defaults `createdAt` to now; a caller reconstructing a persisted session passes the original `createdAt` and persisted `seedLength` to preserve them. Disposed with the calling fiber.
|
||||
- `ctx.sessions.fork(source, boundary?, childSessionId?): Session` — Resolve a live session object or id, select a seed through the inclusive `boundary` event seq (default: current last event), require that boundary to be `turn/end`, and create a live child session with lineage metadata.
|
||||
- `ctx.sessions.get(id: SessionId): Session | undefined`
|
||||
- `ctx.sessions.list(): Session[]`
|
||||
|
||||
@@ -50,11 +51,11 @@ Plain class (not a Cordis Service). Create via `ctx.sessions.create()`.
|
||||
|
||||
### Request-header reconstruction (`request-header.ts`)
|
||||
|
||||
The `request/header` (full `EpochHeader` snapshot with a `RequestHeaderReason`) and `request/header-delta` (system line-trim / name-keyed tools delta / whole config) events make the request envelope logged session state, so every conversation request is a pure function of the log. The pure trio reconstructs it: `foldRequestHeader(events)` folds a log (or any prefix) into the header in force; `diffHeader(prev, next)` encodes a change (undefined when equal); `applyHeaderDelta(prev, delta)` replays one. Writer contract: every logged delta is round-trip-verified (`apply(prev, delta)` deep-equals the new header) with a `'fallback'` snapshot when the encoding cannot express the change (a pure tool reordering), so folding never needs error recovery on a well-formed log. `canonicalHeader` pins the one representation of absence (empty system/tools ≡ absent fields).
|
||||
The `request/header` (full `EpochHeader` snapshot with a `RequestHeaderReason`) and `request/header-delta` (system line-trim / name-keyed tools delta / whole config / whole session prefix) events make the request envelope logged session state, so every conversation request is a pure function of the log. The pure trio reconstructs it: `foldRequestHeader(events)` folds a log (or any prefix) into the header in force; `diffHeader(prev, next)` encodes a change (undefined when equal); `applyHeaderDelta(prev, delta)` replays one. Writer contract: every logged delta is round-trip-verified (`apply(prev, delta)` deep-equals the new header) with a `'fallback'` snapshot when the encoding cannot express the change (a pure tool reordering), so folding never needs error recovery on a well-formed log. `canonicalHeader` pins the one representation of absence (empty system/tools/messagePrefix ≡ absent fields; a delta's EMPTY prefix array encodes the transition back to absence). `EpochHeader.messagePrefix` is the durable record of the `agent/session-prefix` waterfall's product — composed once per loop instance, the request is `messagePrefix + derived history`, and `deriveMessages()` never returns it.
|
||||
|
||||
### Session event vocabulary (`types.ts`)
|
||||
|
||||
The append-only log's event types, enumerated member by member — payloads, surface badges, provenance — in the generated [persistence log event catalog](../../../docs/persistence-catalog/log-events.md). Token usage rides on `assistant/message.usage`; an operational error's step is on `turn/end.reason` for `kind: 'error'`.
|
||||
The append-only log's event types, enumerated member by member — payloads, surface badges, provenance — in the generated [persistence log event catalog](../../../docs/persistence-catalog.md). Token usage rides on `assistant/message.usage`; an operational error's step is on `turn/end.reason` for `kind: 'error'`.
|
||||
|
||||
Merge-extensible via `SessionEventMap` — a plugin declaration-merges its own types (the compaction seam's `compact/*`, the hook bridges' `hook/*`); merged members appear in the same catalog.
|
||||
|
||||
@@ -72,9 +73,9 @@ Every `SessionEvent` carries two optional top-level fields (structural metadata)
|
||||
### Extension points
|
||||
|
||||
- Persistence plugins: subscribe to `session/event` (write-behind) and drain on `session/flush` (awaited) and fiber dispose. A durable backend reads the log and reloads it into a live session; the metadata seam (`SessionHeader`, `session.header`) is what such a backend stores beside the log.
|
||||
- Replay/fork: `ctx.sessions.create(id, { seed })` seeds a new session with an existing event log. The surface rebuilds deterministically from `surfaceOp` markers in the seeded events. The seed is validated to the SAME invariants `append` enforces — including that every surface-eligible event (`SurfaceEventType`) carries a `surfaceOp` marker — so a marker-less message event is rejected at construction rather than silently vanishing from `deriveMessages()` (the surface is the sole derivation path) on resume.
|
||||
- Replay/fork: `ctx.sessions.create(id, { seed })` seeds a new session with an existing event log. The surface rebuilds deterministically from `surfaceOp` markers in the seeded events. The seed is validated to the SAME always-on invariants `append` enforces — contiguous seqs, JSON-serializable data, and required `surfaceOp` markers on surface-eligible events — so marker-less message events are rejected at construction rather than silently vanishing from `deriveMessages()`. Broader turn-enclosure checks stay in `dsh-invariants` and persistence repair. Ordinary live-session forks use `ctx.sessions.fork(source, boundary?, childSessionId?)`, where `boundary` is the inclusive source event seq to fork through.
|
||||
- Compaction: the `dsh-compact-basic` plugin appends a `user/message` with `surfaceOp: { op: 'replace', start, end }` to shadow old surface nodes behind a summary checkpoint.
|
||||
|
||||
### What is NOT here (TODO)
|
||||
|
||||
- **Session branching/tree** (pi-style entry tree) — deferred unless needed beyond seed-based forking.
|
||||
- **Session branching/tree** (pi-style entry tree) — deferred unless needed beyond boundary-based `fork()`.
|
||||
|
||||
@@ -153,10 +153,15 @@ export class Session {
|
||||
this.header = header ?? { version: SESSION_FORMAT_VERSION, id, createdAt: Date.now() }
|
||||
}
|
||||
|
||||
/**
|
||||
* The append-only event log, exposed live by reference (readonly-typed, not
|
||||
* a snapshot): later appends are visible through the same array.
|
||||
*/
|
||||
get events(): readonly SessionEvent[] {
|
||||
return this.log
|
||||
}
|
||||
|
||||
/** The next event's sequence number — always the log length (the `seq = log.length` contiguity contract). */
|
||||
get seq(): number {
|
||||
return this.log.length
|
||||
}
|
||||
@@ -175,6 +180,9 @@ export class Session {
|
||||
* declare how it joins the surface, the sole source of derived history) and
|
||||
* rejected by the compiler for non-surface types like `turn/start` or
|
||||
* `assistant/chunk`.
|
||||
* @returns the logged event — its assigned `seq`/`time` plus the SNAPSHOT of
|
||||
* `data` that entered the log, so reading `event.data` back sees the logged
|
||||
* value, never the caller's still-mutable input.
|
||||
* @throws if `data` is not losslessly JSON-serializable (BigInt, function,
|
||||
* symbol, undefined, non-finite number, circular ref, or an exotic object
|
||||
* like Map/Set/Date). The event log is the durable source of truth, so this
|
||||
@@ -362,6 +370,32 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
/** A fork source: either the live session object or its live store id. */
|
||||
export type SessionForkSource = Session | SessionId
|
||||
|
||||
/**
|
||||
* Rejection codes for session forking: the fork source id is unknown to the
|
||||
* live store (`SESSION_NOT_FOUND`) or names a session object that is not the
|
||||
* store's live instance (`SESSION_NOT_LIVE`); the requested child id is
|
||||
* already taken (`SESSION_ALREADY_EXISTS`); the boundary is not a contiguous
|
||||
* existing seq (`INVALID_BOUNDARY`); or the boundary event is not a
|
||||
* `turn/end` — a fork must cut on a closed turn (`OPEN_TURN`).
|
||||
*/
|
||||
export type SessionForkErrorCode =
|
||||
| 'SESSION_NOT_FOUND'
|
||||
| 'SESSION_NOT_LIVE'
|
||||
| 'SESSION_ALREADY_EXISTS'
|
||||
| 'INVALID_BOUNDARY'
|
||||
| 'OPEN_TURN'
|
||||
|
||||
/** Typed error for session fork rejections. */
|
||||
export class SessionForkError extends Error {
|
||||
constructor(message: string, public readonly code: SessionForkErrorCode) {
|
||||
super(message)
|
||||
this.name = 'SessionForkError'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory session store (`ctx.sessions`).
|
||||
*
|
||||
@@ -496,6 +530,92 @@ export class SessionStore extends Service {
|
||||
list(): Session[] {
|
||||
return [...this.store.values()]
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a live child session from a turn-enclosed prefix of a live source.
|
||||
* `boundary` is an inclusive source event seq; omitted means the source's
|
||||
* current last event. A non-empty selected slice must end at `turn/end`.
|
||||
*
|
||||
* @param source - Live source session object or id.
|
||||
* @param boundary - Inclusive source event seq to fork through; omitted means
|
||||
* the source's current last event, and omitted on an empty source forks an
|
||||
* empty child.
|
||||
* @param childSessionId - Optional child session id; omitted delegates to
|
||||
* `SessionStore`'s id policy.
|
||||
* @returns The created live child session.
|
||||
*/
|
||||
fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session {
|
||||
if (childSessionId !== undefined && this.get(childSessionId) !== undefined) {
|
||||
throw new SessionForkError(`session "${childSessionId}" already exists`, 'SESSION_ALREADY_EXISTS')
|
||||
}
|
||||
const liveSource = this._resolveForkSource(source)
|
||||
const seed = this._forkSeed(liveSource, boundary)
|
||||
return this.create(childSessionId, {
|
||||
seed,
|
||||
meta: {
|
||||
...liveSource.header.cwd !== undefined ? { cwd: liveSource.header.cwd } : {},
|
||||
parentSession: liveSource.id,
|
||||
seedLength: seed.length,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
private _forkSeed(session: Session, requestedBoundary: number | undefined): SessionEvent[] {
|
||||
const events = session.events
|
||||
const lastEvent = events.at(-1)
|
||||
let boundary: number
|
||||
if (requestedBoundary !== undefined) {
|
||||
boundary = requestedBoundary
|
||||
} else {
|
||||
if (lastEvent === undefined) return []
|
||||
boundary = lastEvent.seq
|
||||
}
|
||||
if (!Number.isSafeInteger(boundary) || boundary < 0) {
|
||||
throw new SessionForkError(
|
||||
`fork boundary for session "${session.id}" must be a non-negative safe integer, got ${String(boundary)}`,
|
||||
'INVALID_BOUNDARY',
|
||||
)
|
||||
}
|
||||
if (boundary >= events.length) {
|
||||
const lastSeq = events.at(-1)?.seq
|
||||
throw new SessionForkError(
|
||||
`fork boundary ${boundary} does not exist in session "${session.id}" (last seq: ${lastSeq ?? 'none'})`,
|
||||
'INVALID_BOUNDARY',
|
||||
)
|
||||
}
|
||||
|
||||
const boundaryEvent = events[boundary]
|
||||
if (boundaryEvent === undefined || boundaryEvent.seq !== boundary) {
|
||||
throw new SessionForkError(
|
||||
`fork boundary ${boundary} does not match a contiguous event seq in session "${session.id}"`,
|
||||
'INVALID_BOUNDARY',
|
||||
)
|
||||
}
|
||||
if (boundaryEvent.type !== 'turn/end') {
|
||||
throw new SessionForkError(
|
||||
`fork boundary ${boundary} in session "${session.id}" must be turn/end, got ${boundaryEvent.type}`,
|
||||
'OPEN_TURN',
|
||||
)
|
||||
}
|
||||
|
||||
return events.slice(0, boundary + 1).map(event => structuredClone(event))
|
||||
}
|
||||
|
||||
private _resolveForkSource(source: SessionForkSource): Session {
|
||||
if (typeof source === 'string') {
|
||||
const session = this.get(source)
|
||||
if (session === undefined) throw new SessionForkError(`session "${source}" not found`, 'SESSION_NOT_FOUND')
|
||||
return session
|
||||
}
|
||||
|
||||
const live = this.get(source.id)
|
||||
if (live === undefined) {
|
||||
throw new SessionForkError(`session "${source.id}" not found`, 'SESSION_NOT_FOUND')
|
||||
}
|
||||
if (live !== source) throw new SessionForkError(`session "${source.id}" is not the live store instance`, 'SESSION_NOT_LIVE')
|
||||
return source
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default SessionStore
|
||||
|
||||
@@ -40,6 +40,10 @@ export type JsonValue = null | boolean | number | string | JsonValue[] | { [key:
|
||||
* hiding under a symbol/non-enumerable key cannot make the round-trip lossy.
|
||||
* Getters are invoked during the check (again as `JSON.stringify` would), so the
|
||||
* contract is for plain data records, not objects with side-effecting accessors.
|
||||
* @param value - the candidate event data to test.
|
||||
* @param seen - objects on the current descent path, for circular-reference
|
||||
* detection; the recursion threads it — callers omit it.
|
||||
* @returns true when `value` survives a JSON round-trip losslessly.
|
||||
*/
|
||||
export function isJsonValue(value: unknown, seen: Set<object> = new Set()): boolean {
|
||||
if (value === null) return true
|
||||
|
||||
@@ -54,6 +54,8 @@ import type { SessionEvent } from './types.ts'
|
||||
* Only the LAST turn can be open: the invariants plugin guarantees a `turn/end`
|
||||
* before any later `turn/start`, so an interior open turn is impossible in a
|
||||
* valid committed log. Likewise at most one step is open within that turn.
|
||||
* @param events - the loaded durable log to scan (a valid committed prefix, possibly with a crash tail).
|
||||
* @returns the synthetic closer events to append after `events`, in order; empty when the log is already balanced.
|
||||
*/
|
||||
export function interruptedTurnClosers(events: readonly SessionEvent[]): SessionEvent[] {
|
||||
let openTurn: number | null = null
|
||||
|
||||
@@ -13,15 +13,23 @@
|
||||
*/
|
||||
|
||||
import { callConfigEquals } from '@deepseek-ai/dsh-llm'
|
||||
import type { LlmCallConfig, ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
import type { LlmCallConfig, Message, ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
import type { EpochHeader, SessionEvent, SystemDelta, ToolsDelta } from './types.ts'
|
||||
|
||||
/** The `request/header-delta` payload shape: each present field amends the folded header. */
|
||||
type HeaderDelta = {
|
||||
system?: SystemDelta
|
||||
tools?: ToolsDelta
|
||||
config?: LlmCallConfig
|
||||
messagePrefix?: Message[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a header to canonical form: an empty system prompt and an empty
|
||||
* tool list become ABSENT fields, matching how requests are built (both
|
||||
* request-build spreads skip empty values). Diff, fold, and comparison all
|
||||
* operate on canonical headers, so "no system prompt" has exactly one
|
||||
* representation.
|
||||
* Normalize a header to canonical form: an empty system prompt, an empty
|
||||
* tool list, and an empty session prefix become ABSENT fields, matching how
|
||||
* requests are built (the request-build spreads skip empty values). Diff,
|
||||
* fold, and comparison all operate on canonical headers, so "no system
|
||||
* prompt" (and "no session prefix") has exactly one representation.
|
||||
* @param header - the header to normalize (not mutated).
|
||||
* @returns the canonical header.
|
||||
*/
|
||||
@@ -30,6 +38,7 @@ export function canonicalHeader(header: EpochHeader): EpochHeader {
|
||||
config: header.config,
|
||||
...header.system !== undefined && header.system.length > 0 ? { system: header.system } : {},
|
||||
...header.tools !== undefined && header.tools.length > 0 ? { tools: header.tools } : {},
|
||||
...header.messagePrefix !== undefined && header.messagePrefix.length > 0 ? { messagePrefix: header.messagePrefix } : {},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,37 +118,46 @@ function applyTools(prev: readonly ToolSchema[], delta: ToolsDelta): ToolSchema[
|
||||
* writer's round-trip guard runs (`applyHeaderDelta(prev, delta)` must equal
|
||||
* the intended header) and the loop runs to skip logging an unchanged header.
|
||||
* Tools compare per-schema IN ORDER (canonical JSON), so a pure reordering is
|
||||
* correctly unequal.
|
||||
* correctly unequal; the session prefix compares as canonical JSON (both
|
||||
* sides come from the same build path, so key order matches when the values
|
||||
* do).
|
||||
* @param a - one canonical header.
|
||||
* @param b - the other.
|
||||
* @returns whether config, system, and tools (in order) all match.
|
||||
* @returns whether config, system, tools (in order), and the session prefix all match.
|
||||
*/
|
||||
export function headerEquals(a: EpochHeader, b: EpochHeader): boolean {
|
||||
if (!callConfigEquals(a.config, b.config) || a.system !== b.system) return false
|
||||
if (!sameMessages(a.messagePrefix, b.messagePrefix)) return false
|
||||
const at = a.tools ?? []
|
||||
const bt = b.tools ?? []
|
||||
return at.length === bt.length && at.every((tool, i) => sameSchema(tool, bt[i] as ToolSchema))
|
||||
}
|
||||
|
||||
/** Canonical JSON equality over session-prefix arrays; absence equals the empty array. */
|
||||
function sameMessages(a: readonly Message[] | undefined, b: readonly Message[] | undefined): boolean {
|
||||
return JSON.stringify(a ?? []) === JSON.stringify(b ?? [])
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the `request/header-delta` payload between two canonical headers,
|
||||
* or undefined when they are equal. The caller MUST round-trip the result
|
||||
* ({@link applyHeaderDelta} on `prev` deep-equals `next`) before logging it —
|
||||
* the encoding cannot express every change (a pure tool reordering) — and
|
||||
* fall back to a full `request/header` snapshot when the check fails.
|
||||
* The session prefix is replaced whole (small advisory content, not worth
|
||||
* diffing); an empty replacement array encodes the transition to "none".
|
||||
* @param prev - the folded header the log currently implies.
|
||||
* @param next - the header the next request will actually use.
|
||||
* @returns the delta payload, or undefined when nothing changed.
|
||||
*/
|
||||
export function diffHeader(
|
||||
prev: EpochHeader, next: EpochHeader,
|
||||
): { system?: SystemDelta; tools?: ToolsDelta; config?: LlmCallConfig } | undefined {
|
||||
const delta: { system?: SystemDelta; tools?: ToolsDelta; config?: LlmCallConfig } = {}
|
||||
export function diffHeader(prev: EpochHeader, next: EpochHeader): HeaderDelta | undefined {
|
||||
const delta: HeaderDelta = {}
|
||||
if (prev.system !== next.system) delta.system = diffSystem(prev.system, next.system)
|
||||
const prevTools = prev.tools ?? []
|
||||
const nextTools = next.tools ?? []
|
||||
if (JSON.stringify(prevTools) !== JSON.stringify(nextTools)) delta.tools = diffTools(prevTools, nextTools)
|
||||
if (!callConfigEquals(prev.config, next.config)) delta.config = next.config
|
||||
if (!sameMessages(prev.messagePrefix, next.messagePrefix)) delta.messagePrefix = next.messagePrefix ?? []
|
||||
return Object.keys(delta).length > 0 ? delta : undefined
|
||||
}
|
||||
|
||||
@@ -151,15 +169,15 @@ export function diffHeader(
|
||||
* @param delta - the logged delta payload.
|
||||
* @returns the canonical header after the delta.
|
||||
*/
|
||||
export function applyHeaderDelta(
|
||||
prev: EpochHeader, delta: { system?: SystemDelta; tools?: ToolsDelta; config?: LlmCallConfig },
|
||||
): EpochHeader {
|
||||
export function applyHeaderDelta(prev: EpochHeader, delta: HeaderDelta): EpochHeader {
|
||||
const system = delta.system !== undefined ? applySystem(prev.system, delta.system) : prev.system
|
||||
const tools = delta.tools !== undefined ? applyTools(prev.tools ?? [], delta.tools) : prev.tools
|
||||
const messagePrefix = delta.messagePrefix ?? prev.messagePrefix
|
||||
return canonicalHeader({
|
||||
config: delta.config ?? prev.config,
|
||||
...system !== undefined ? { system } : {},
|
||||
...tools !== undefined ? { tools } : {},
|
||||
...messagePrefix !== undefined ? { messagePrefix } : {},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,8 @@ const SURFACE_EVENT_TYPES = new Set<string>([
|
||||
* surface-eligible event that is MISSING its mandatory marker (e.g. validating
|
||||
* a seed/load log); use {@link isSurfaceEvent} to narrow to a fully-formed
|
||||
* {@link SurfaceEvent} with `surfaceOp` present.
|
||||
* @param type - the event type string to test.
|
||||
* @returns true when the type is one of the five message-producing types.
|
||||
*/
|
||||
export function isSurfaceEligibleType(type: string): boolean {
|
||||
return SURFACE_EVENT_TYPES.has(type)
|
||||
@@ -38,6 +40,8 @@ export function isSurfaceEligibleType(type: string): boolean {
|
||||
* Narrow a {@link SessionEvent} to {@link SurfaceEvent}: checks that the
|
||||
* event's `type` is surface-eligible AND that `surfaceOp` is present.
|
||||
* The narrowed type has mandatory {@link SurfaceOp}.
|
||||
* @param event - the event to narrow.
|
||||
* @returns true when the event is surface-eligible and carries its `surfaceOp` marker.
|
||||
*/
|
||||
export function isSurfaceEvent(event: SessionEvent): event is SurfaceEvent {
|
||||
if (!SURFACE_EVENT_TYPES.has(event.type)) return false
|
||||
|
||||
@@ -74,6 +74,12 @@ function nodeDelta(event: SessionEvent): number {
|
||||
* surface successor (`SurfaceNode.next`), or `null` when `end` is the tail —
|
||||
* for the cut after `end`.
|
||||
*
|
||||
* @param nodes - the surface linked list in head→tail order.
|
||||
* @param events - the session log each node's `seq` indexes into.
|
||||
* @param beforeSeq - names the cut (the node it sits immediately before);
|
||||
* `null` — or any seq not on the surface — means the after-tail cut.
|
||||
* @returns true when every `tool-call` before the cut is answered before it
|
||||
* (the unanswered-call depth at the cut is zero).
|
||||
* @throws if the surface prefix drives the unanswered-call depth negative — a
|
||||
* `tool/result` with no preceding open `tool-call` on the surface. That is a
|
||||
* corrupt surface (a structural invariant violation), surfaced loudly here
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
import type { CallId, ContentBlock, LlmCallConfig, MessageSource, StreamChunk, TokenUsage, ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
import type { CallId, ContentBlock, LlmCallConfig, Message, MessageSource, StreamChunk, TokenUsage, ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
/** Identifies one session in the store (and its persistence artifacts). */
|
||||
export type SessionId = Branded<'SessionId'>
|
||||
|
||||
/** Brand a string as a {@link SessionId}. */
|
||||
/**
|
||||
* Brand a string as a {@link SessionId}.
|
||||
* @param id - the raw session id string.
|
||||
* @returns the same string, branded (a compile-time cast — no runtime cost).
|
||||
*/
|
||||
export function SessionId(id: string): SessionId {
|
||||
return id as SessionId
|
||||
}
|
||||
@@ -102,6 +106,7 @@ export interface TurnTriggerMap {
|
||||
injection: { kind: 'injection'; source: MessageSource }
|
||||
}
|
||||
|
||||
/** The union over {@link TurnTriggerMap} — what started a turn; plugins extend it by merging variants into the map. */
|
||||
export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap]
|
||||
|
||||
/**
|
||||
@@ -156,6 +161,7 @@ export interface TurnEndReasonMap {
|
||||
interrupted: { kind: 'interrupted' }
|
||||
}
|
||||
|
||||
/** The union over {@link TurnEndReasonMap} — why a turn ended; plugins extend it by merging variants into the map. */
|
||||
export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap]
|
||||
|
||||
/**
|
||||
@@ -177,14 +183,15 @@ export interface TodoItem {
|
||||
}
|
||||
|
||||
/**
|
||||
* The request header: everything about an LLM request besides its message
|
||||
* content — the call configuration plus the rendered system prompt and tool
|
||||
* schemas. Logged session state (the reconstructability RFC): a
|
||||
* The request header: everything about an LLM request besides its derived
|
||||
* message history — the call configuration plus the rendered system prompt,
|
||||
* tool schemas, and the session prefix. Logged session state (the
|
||||
* reconstructability RFC): a
|
||||
* {@link SessionEventMap} `request/header` snapshot installs one, a
|
||||
* `request/header-delta` amends it, and folding those events over the log
|
||||
* (`foldRequestHeader`) reconstructs the header any request was built under.
|
||||
* Canonical form: an empty system prompt and an empty tool list are ABSENT
|
||||
* fields, matching how requests are built.
|
||||
* Canonical form: an empty system prompt, an empty tool list, and an empty
|
||||
* prefix are ABSENT fields, matching how requests are built.
|
||||
*/
|
||||
export interface EpochHeader {
|
||||
/** The conversation's call configuration (model + sampling scalars). */
|
||||
@@ -193,6 +200,14 @@ export interface EpochHeader {
|
||||
system?: string
|
||||
/** Assembled tool schemas; absent for a tool-less request. */
|
||||
tools?: ToolSchema[]
|
||||
/**
|
||||
* The session prefix: request-only messages sent BEFORE the entire derived
|
||||
* history (the `agent/session-prefix` waterfall's product, composed once
|
||||
* per loop instance and reused for every request it sends). Not session
|
||||
* history — `deriveMessages()` never returns it — so the header is its
|
||||
* only durable record; absent when the instance composed none.
|
||||
*/
|
||||
messagePrefix?: Message[]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -350,17 +365,24 @@ export interface SessionEventMap {
|
||||
'request/header': { header: EpochHeader; reason: RequestHeaderReason }
|
||||
/**
|
||||
* Amendment to the folded {@link EpochHeader}: at least one of a
|
||||
* {@link SystemDelta}, a {@link ToolsDelta}, or a whole replacement
|
||||
* {@link LlmCallConfig} (four scalars — not worth diffing). Appended by the
|
||||
* {@link SystemDelta}, a {@link ToolsDelta}, a whole replacement
|
||||
* {@link LlmCallConfig} (four scalars — not worth diffing), or a whole
|
||||
* replacement session prefix (`messagePrefix` — small advisory content,
|
||||
* replaced whole; an EMPTY array encodes the transition to "none",
|
||||
* mirroring the canonical form's absent field — the loop never produces
|
||||
* one in practice: the prefix is composed once per instance and anchored
|
||||
* by that instance's snapshot, so this arm exists for codec totality).
|
||||
* Appended by the
|
||||
* loop inside the step, before dispatch, when the header for this request
|
||||
* differs from the fold of the log so far; the writer verifies
|
||||
* `applyHeaderDelta(previous, delta)` reproduces the new header exactly and
|
||||
* falls back to a `'fallback'` `request/header` snapshot when it cannot, so
|
||||
* a logged delta ALWAYS round-trips. NOT a {@link SurfaceEventType}.
|
||||
*/
|
||||
'request/header-delta': { system?: SystemDelta; tools?: ToolsDelta; config?: LlmCallConfig }
|
||||
'request/header-delta': { system?: SystemDelta; tools?: ToolsDelta; config?: LlmCallConfig; messagePrefix?: Message[] }
|
||||
}
|
||||
|
||||
/** The appendable event-type keys of {@link SessionEventMap}, plugin-merged extensions included. */
|
||||
export type SessionEventType = keyof SessionEventMap
|
||||
|
||||
/**
|
||||
|
||||
240
packages/core/session/tests/fork.spec.ts
Normal file
240
packages/core/session/tests/fork.spec.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { Session, SessionForkError, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
|
||||
async function setup(): Promise<{ ctx: Context; sessions: SessionStore }> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
return { ctx, sessions: ctx.sessions }
|
||||
}
|
||||
|
||||
function appendClosedTurn(
|
||||
session: Session,
|
||||
turn: number,
|
||||
text = `hello ${turn}`,
|
||||
reason: TurnEndReason = { kind: 'completed' },
|
||||
): void {
|
||||
session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('user/message', {
|
||||
content: [{ type: 'text', text }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn, reason })
|
||||
}
|
||||
|
||||
function appendOpenTurn(session: Session, turn: number): void {
|
||||
session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('user/message', {
|
||||
content: [{ type: 'text', text: `open ${turn}` }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
}
|
||||
|
||||
function firstUserMessage(events: readonly SessionEvent[]): SessionEvent<'user/message'> {
|
||||
const event = events.find((e): e is SessionEvent<'user/message'> => e.type === 'user/message')
|
||||
if (event === undefined) throw new Error('missing user/message')
|
||||
return event
|
||||
}
|
||||
|
||||
function lastSeq(session: Session): number {
|
||||
const event = session.events.at(-1)
|
||||
if (event === undefined) throw new Error('missing last event')
|
||||
return event.seq
|
||||
}
|
||||
|
||||
describe('SessionStore.fork', () => {
|
||||
it('forks an empty live session as an empty child with lineage metadata', async () => {
|
||||
const { ctx, sessions } = await setup()
|
||||
const source = ctx.sessions.create(SessionId('empty-parent'), { meta: { cwd: '/workspace' } })
|
||||
|
||||
const child = sessions.fork(source, undefined, SessionId('empty-child'))
|
||||
|
||||
expect(child.events).toEqual([])
|
||||
expect(child.header).toMatchObject({
|
||||
id: SessionId('empty-child'),
|
||||
cwd: '/workspace',
|
||||
parentSession: SessionId('empty-parent'),
|
||||
seedLength: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('forks the latest completed boundary by default and deep-clones seed events', async () => {
|
||||
const { ctx, sessions } = await setup()
|
||||
const source = ctx.sessions.create(SessionId('parent'), { meta: { cwd: '/workspace' } })
|
||||
appendClosedTurn(source, 1, 'hello')
|
||||
|
||||
const child = sessions.fork(SessionId('parent'), undefined, SessionId('child'))
|
||||
|
||||
expect(child.events).toEqual(source.events)
|
||||
expect(child.events).not.toBe(source.events)
|
||||
expect(child.events[1]).not.toBe(source.events[1])
|
||||
firstUserMessage(child.events).data.content[0] = { type: 'text', text: 'child mutation' }
|
||||
expect(firstUserMessage(source.events).data.content).toEqual([{ type: 'text', text: 'hello' }])
|
||||
expect(child.header).toMatchObject({
|
||||
id: SessionId('child'),
|
||||
cwd: '/workspace',
|
||||
parentSession: SessionId('parent'),
|
||||
seedLength: source.events.length,
|
||||
})
|
||||
})
|
||||
|
||||
it('forks from an earlier turn boundary even when the source currently has an open tail', async () => {
|
||||
const { ctx, sessions } = await setup()
|
||||
const source = ctx.sessions.create(SessionId('parent'), { meta: { cwd: '/workspace' } })
|
||||
appendClosedTurn(source, 1, 'first')
|
||||
const firstBoundary = lastSeq(source)
|
||||
appendClosedTurn(source, 2, 'second')
|
||||
appendOpenTurn(source, 3)
|
||||
|
||||
const child = sessions.fork(source, firstBoundary, SessionId('child-from-first'))
|
||||
|
||||
expect(child.events).toEqual(source.events.slice(0, firstBoundary + 1))
|
||||
expect(child.header.seedLength).toBe(firstBoundary + 1)
|
||||
expect(child.deriveMessages()).toEqual([{ role: 'user', content: [{ type: 'text', text: 'first' }] }])
|
||||
})
|
||||
|
||||
it('accepts every turn/end reason as an explicit fork boundary', async () => {
|
||||
const { ctx, sessions } = await setup()
|
||||
const reasons: TurnEndReason[] = [
|
||||
{ kind: 'completed' },
|
||||
{ kind: 'aborted', reason: 'cancelled by user' },
|
||||
{ kind: 'error', step: 1, message: 'model failed', code: 'MODEL' },
|
||||
{ kind: 'disposed' },
|
||||
{ kind: 'max-tokens' },
|
||||
{ kind: 'interrupted' },
|
||||
]
|
||||
|
||||
for (const reason of reasons) {
|
||||
const source = ctx.sessions.create(SessionId(`parent-${reason.kind}`))
|
||||
appendClosedTurn(source, 1, reason.kind, reason)
|
||||
|
||||
const child = sessions.fork(source, lastSeq(source), SessionId(`child-${reason.kind}`))
|
||||
|
||||
expect(child.events.at(-1)?.type).toBe('turn/end')
|
||||
expect(child.header.seedLength).toBe(source.events.length)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects invalid boundaries before creating a child', async () => {
|
||||
const { ctx, sessions } = await setup()
|
||||
const empty = ctx.sessions.create(SessionId('empty'))
|
||||
expect(() => sessions.fork(empty, 0, SessionId('empty-child')))
|
||||
.toThrow(new SessionForkError('fork boundary 0 does not exist in session "empty" (last seq: none)', 'INVALID_BOUNDARY'))
|
||||
expect(ctx.sessions.get(SessionId('empty-child'))).toBeUndefined()
|
||||
|
||||
const source = ctx.sessions.create(SessionId('parent'))
|
||||
appendClosedTurn(source, 1)
|
||||
expect(() => sessions.fork(source, -1, SessionId('negative')))
|
||||
.toThrow(/non-negative safe integer/)
|
||||
expect(() => sessions.fork(source, 0.5, SessionId('fraction')))
|
||||
.toThrow(/non-negative safe integer/)
|
||||
expect(() => sessions.fork(source, Number.MAX_SAFE_INTEGER + 1, SessionId('unsafe')))
|
||||
.toThrow(/non-negative safe integer/)
|
||||
expect(() => sessions.fork(source, source.seq, SessionId('past-end')))
|
||||
.toThrow(new SessionForkError(`fork boundary ${source.seq} does not exist in session "parent" (last seq: ${source.seq - 1})`, 'INVALID_BOUNDARY'))
|
||||
})
|
||||
|
||||
it('rejects a corrupted live source whose array index no longer matches event seq', async () => {
|
||||
const { ctx, sessions } = await setup()
|
||||
const source = ctx.sessions.create(SessionId('corrupt-parent'))
|
||||
appendClosedTurn(source, 1)
|
||||
const mutableLog = (source as unknown as { log: SessionEvent[] }).log
|
||||
mutableLog[2] = { ...mutableLog[2]!, seq: 99 }
|
||||
|
||||
expect(() => sessions.fork(source, 2, SessionId('corrupt-child')))
|
||||
.toThrow(new SessionForkError('fork boundary 2 does not match a contiguous event seq in session "corrupt-parent"', 'INVALID_BOUNDARY'))
|
||||
expect(ctx.sessions.get(SessionId('corrupt-child'))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects an unknown live session id', async () => {
|
||||
const { sessions } = await setup()
|
||||
|
||||
expect(() => sessions.fork(SessionId('missing')))
|
||||
.toThrow(new SessionForkError('session "missing" not found', 'SESSION_NOT_FOUND'))
|
||||
})
|
||||
|
||||
it('rejects a detached Session object that is not live in ctx.sessions', async () => {
|
||||
const { sessions } = await setup()
|
||||
const detached = new Session(SessionId('detached'))
|
||||
|
||||
expect(() => sessions.fork(detached))
|
||||
.toThrow(new SessionForkError('session "detached" not found', 'SESSION_NOT_FOUND'))
|
||||
})
|
||||
|
||||
it('rejects a stale Session object whose id is live on a different instance', async () => {
|
||||
const { ctx, sessions } = await setup()
|
||||
ctx.sessions.create(SessionId('same-id'))
|
||||
const stale = new Session(SessionId('same-id'))
|
||||
|
||||
expect(() => sessions.fork(stale))
|
||||
.toThrow(new SessionForkError('session "same-id" is not the live store instance', 'SESSION_NOT_LIVE'))
|
||||
})
|
||||
|
||||
it('rejects selected slices whose boundary is inside an open turn', async () => {
|
||||
const { ctx, sessions } = await setup()
|
||||
const cases: [string, (session: Session) => number][] = [
|
||||
['turn/start', (session) => {
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
return lastSeq(session)
|
||||
}],
|
||||
['step/start', (session) => {
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
return lastSeq(session)
|
||||
}],
|
||||
['user/message', (session) => {
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('user/message', { content: [{ type: 'text', text: 'open' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
return lastSeq(session)
|
||||
}],
|
||||
['assistant/message', (session) => {
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
session.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'partial' }] }, { surfaceOp: 'append' })
|
||||
return lastSeq(session)
|
||||
}],
|
||||
['tool/call', (session) => {
|
||||
const callId = CallId('call-open')
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
session.append('assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
content: [{ type: 'tool-call', id: callId, name: 'bash', arguments: '{}' }],
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('tool/call', { turn: 1, step: 1, callId, name: 'bash', arguments: '{}' })
|
||||
return lastSeq(session)
|
||||
}],
|
||||
]
|
||||
|
||||
for (const [lastType, build] of cases) {
|
||||
const source = ctx.sessions.create(SessionId(`open-${lastType}`))
|
||||
const boundary = build(source)
|
||||
|
||||
expect(() => sessions.fork(source, boundary))
|
||||
.toThrow(new SessionForkError(`fork boundary ${boundary} in session "open-${lastType}" must be turn/end, got ${lastType}`, 'OPEN_TURN'))
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects a child session id that is already live with a typed fork error', async () => {
|
||||
const { ctx, sessions } = await setup()
|
||||
const source = ctx.sessions.create(SessionId('parent'))
|
||||
appendClosedTurn(source, 1)
|
||||
ctx.sessions.create(SessionId('child'))
|
||||
|
||||
expect(() => sessions.fork(source, undefined, SessionId('child')))
|
||||
.toThrow(new SessionForkError('session "child" already exists', 'SESSION_ALREADY_EXISTS'))
|
||||
})
|
||||
|
||||
it('rejects a duplicate child session id before validating the boundary', async () => {
|
||||
const { ctx, sessions } = await setup()
|
||||
const source = ctx.sessions.create(SessionId('open-parent'))
|
||||
source.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
ctx.sessions.create(SessionId('child'))
|
||||
|
||||
expect(() => sessions.fork(source, undefined, SessionId('child')))
|
||||
.toThrow(new SessionForkError('session "child" already exists', 'SESSION_ALREADY_EXISTS'))
|
||||
})
|
||||
})
|
||||
@@ -8,9 +8,9 @@
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Session, SessionId, applyHeaderDelta, canonicalHeader, diffHeader, foldRequestHeader } from '@deepseek-ai/dsh-session'
|
||||
import { Session, SessionId, applyHeaderDelta, canonicalHeader, diffHeader, foldRequestHeader, headerEquals } from '@deepseek-ai/dsh-session'
|
||||
import type { EpochHeader, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
const CONFIG = { model: 'm' }
|
||||
|
||||
@@ -18,6 +18,10 @@ function tool(name: string, description = 'd'): ToolSchema {
|
||||
return { name, description, parameters: { type: 'object' } }
|
||||
}
|
||||
|
||||
function msg(text: string): Message {
|
||||
return { role: 'user', content: [{ type: 'text', text }] }
|
||||
}
|
||||
|
||||
/** Round-trip helper: diff must reproduce `next` from `prev` exactly. */
|
||||
function roundTrip(prev: EpochHeader, next: EpochHeader): ReturnType<typeof diffHeader> {
|
||||
const delta = diffHeader(prev, next)
|
||||
@@ -103,6 +107,48 @@ describe('diffHeader / applyHeaderDelta', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('the session prefix (messagePrefix)', () => {
|
||||
it('canonicalHeader normalizes an empty prefix to an absent field', () => {
|
||||
expect(canonicalHeader({ config: CONFIG, messagePrefix: [] })).toEqual({ config: CONFIG })
|
||||
const full = canonicalHeader({ config: CONFIG, messagePrefix: [msg('p')] })
|
||||
expect(full.messagePrefix).toEqual([msg('p')])
|
||||
})
|
||||
|
||||
it('headerEquals treats absence and empty as one representation, content differences as unequal', () => {
|
||||
expect(headerEquals(canonicalHeader({ config: CONFIG }), { config: CONFIG, messagePrefix: [] })).toBe(true)
|
||||
expect(headerEquals({ config: CONFIG, messagePrefix: [msg('a')] }, { config: CONFIG, messagePrefix: [msg('b')] })).toBe(false)
|
||||
expect(headerEquals({ config: CONFIG, messagePrefix: [msg('a')] }, { config: CONFIG })).toBe(false)
|
||||
})
|
||||
|
||||
it('replaces a changed prefix whole and leaves untouched parts alone', () => {
|
||||
const prev = canonicalHeader({ config: CONFIG, system: 'keep', messagePrefix: [msg('old')] })
|
||||
const next = canonicalHeader({ config: CONFIG, system: 'keep', messagePrefix: [msg('new'), msg('more')] })
|
||||
const delta = roundTrip(prev, next)
|
||||
expect(delta).toEqual({ messagePrefix: [msg('new'), msg('more')] })
|
||||
})
|
||||
|
||||
it('round-trips a prefix gained from a bare header and lost back to one (empty array encodes absence)', () => {
|
||||
const none = canonicalHeader({ config: CONFIG })
|
||||
const some = canonicalHeader({ config: CONFIG, messagePrefix: [msg('p')] })
|
||||
const gained = roundTrip(none, some)
|
||||
expect(gained).toEqual({ messagePrefix: [msg('p')] })
|
||||
const lost = roundTrip(some, none)
|
||||
expect(lost).toEqual({ messagePrefix: [] })
|
||||
})
|
||||
|
||||
it('folds prefix deltas over the log like any other header amendment', () => {
|
||||
const session = new Session(SessionId('fold-prefix'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
const first = canonicalHeader({ config: CONFIG, messagePrefix: [msg('catalog v1')] })
|
||||
session.append('request/header', { header: first, reason: 'initial' })
|
||||
const second = canonicalHeader({ config: CONFIG, messagePrefix: [msg('catalog v2')] })
|
||||
session.append('request/header-delta', diffHeader(first, second)!)
|
||||
expect(foldRequestHeader(session.events)).toEqual(second)
|
||||
session.append('request/header-delta', diffHeader(second, canonicalHeader({ config: CONFIG }))!)
|
||||
expect(foldRequestHeader(session.events)).toEqual({ config: CONFIG })
|
||||
})
|
||||
})
|
||||
|
||||
describe('foldRequestHeader', () => {
|
||||
function headerEvents(session: Session): readonly SessionEvent[] {
|
||||
return session.events
|
||||
|
||||
@@ -61,8 +61,10 @@ describe('Session', () => {
|
||||
|
||||
it('replays identically from a seeded event log', () => {
|
||||
const original = new Session(SessionId('s3'))
|
||||
original.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
original.append('user/message', { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
original.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'a' }] }, { surfaceOp: 'append' })
|
||||
original.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
|
||||
const replayed = new Session(SessionId('s3-replay'), [...original.events])
|
||||
expect(replayed.deriveMessages()).toEqual(original.deriveMessages())
|
||||
@@ -423,7 +425,9 @@ describe('todo/write event', () => {
|
||||
|
||||
it('round-trips through a seeded replay identically (durable, no surfaceOp needed)', () => {
|
||||
const original = new Session(SessionId('t4'))
|
||||
original.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
original.append('todo/write', { todos: [{ content: 'only', status: 'completed' }] })
|
||||
original.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
// Seeding a non-surface event with no surfaceOp must not throw.
|
||||
const replayed = new Session(SessionId('t4-replay'), [...original.events])
|
||||
expect(replayed.events.findLast(e => e.type === 'todo/write')!.data.todos)
|
||||
|
||||
@@ -22,7 +22,11 @@ const RUNTIME_PROVIDER = 'runtime'
|
||||
const RUNTIME_RANK = 250
|
||||
const SKILL_PROMPT_SECTION_ORDER = 1000
|
||||
|
||||
/** Return whether a string is a valid kebab-case skill name. */
|
||||
/**
|
||||
* Return whether a string is a valid kebab-case skill name.
|
||||
* @param name - candidate skill name to validate.
|
||||
* @returns whether the name matches the public skill-name grammar.
|
||||
*/
|
||||
export function isSkillName(name: string): boolean {
|
||||
return SKILL_NAME.test(name)
|
||||
}
|
||||
|
||||
@@ -7,15 +7,16 @@ System prompt assembly registry. Plugins contribute ordered text sections, tool-
|
||||
| Key | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `persona` | `''` | The deployment persona: the ONE deployment-authored prompt fragment, rendered as the order-0 `deployment:persona` section and shared by every agent in the context (subagents included). A template — complete `{{…}}` groups are interpreted strictly against the registered variables (the shipped loop registers `{{model}}`/`{{cwd}}`), with no escape syntax for literal braces yet. Empty ⇒ the section is dropped at render. |
|
||||
| `toolOrder` | — | Explicit model-facing tool order, as a list of `ToolSchema.name`s with one `'<unlisted-tools>'` rest entry (`TOOL_ORDER_REST`): listed tools take their listed position, unlisted tools land at the rest entry in lexicographic name order. Absent ⇒ plain lexicographic name order. Applied to the collected tools BEFORE the `system-prompt/assemble` waterfall — like the sections' `order` sort, it canonicalizes what the registry contributed (registration order is a plugin-load artifact), and a waterfall listener that mutates the list owns the determinism of what it emits. Misconfiguration fails loud: a list without exactly one rest entry, or with duplicates, throws at load; a listed name with no registered tool rejects every `assemble()`; a tool provider returning the reserved rest-entry name also rejects. Under the shipped loop the turn fails before any model request. Why a central list and not per-plugin weights: [Explicit model-facing tool order](../../../docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.md). |
|
||||
|
||||
## Service: `SystemPrompt` (ctx key: `systemPrompt`)
|
||||
|
||||
### Public API
|
||||
|
||||
- `ctx.systemPrompt.section(section: PromptSection): () => void` Contribute a section. Duplicate names throw. 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.tools(provider: () => ToolSchema[]): () => void` Contribute tool schemas (evaluated at each assembly). A provider must not return a schema named `TOOL_ORDER_REST`; that name is reserved for `toolOrder`'s rest entry. Disposed with the calling fiber.
|
||||
- `ctx.systemPrompt.variable(name: string, provider: (context) => string | undefined): () => void` Contribute a prompt variable, referenced from section text as `{{name}}`. Duplicate or unreferenceable names throw; `undefined` means "no value for this assembly". Disposed with the calling fiber.
|
||||
- `ctx.systemPrompt.assemble(context?: AssembleContext): Promise<PromptAssembly>` Assemble the prompt for one caller. Runs through the `system-prompt/assemble` waterfall.
|
||||
- `ctx.systemPrompt.assemble(context?: AssembleContext): Promise<PromptAssembly>` Assemble the prompt for one caller. Runs through the `system-prompt/assemble` waterfall. Rejects when a configured `toolOrder` names a tool no provider contributed, or when a provider returns the reserved rest-entry name.
|
||||
|
||||
### Events
|
||||
|
||||
|
||||
@@ -88,7 +88,8 @@ export interface AssembledSection {
|
||||
*
|
||||
* Tool schemas are part of the assembly by design: "what the model is told it
|
||||
* can do" is one coherent thing managed here, even though adapters transmit
|
||||
* `tools` as a separate wire field rather than prompt text.
|
||||
* `tools` as a separate wire field rather than prompt text. They arrive in
|
||||
* the canonical model-facing order (see {@link Config.toolOrder}).
|
||||
*
|
||||
* `variables` carries every registered prompt variable resolved against this
|
||||
* assembly's context — key present means registered, `undefined` value means
|
||||
@@ -110,6 +111,71 @@ const VARIABLE_NAME = /^[a-z][a-z0-9_]*$/
|
||||
/** A complete `{{...}}` reference group at the scan position (validated after). */
|
||||
const GROUP_AT = /^\{\{([^{}]*)\}\}/
|
||||
|
||||
/**
|
||||
* The rest entry for {@link Config.toolOrder}: the position where registered
|
||||
* tools not named in the list are inserted (in lexicographic name order).
|
||||
* Reserved: collected tool schemas using this name are rejected before
|
||||
* ordering, so the marker can never collide with a real model-facing tool.
|
||||
*/
|
||||
export const TOOL_ORDER_REST = '<unlisted-tools>'
|
||||
|
||||
/**
|
||||
* Validate a configured tool-order list's shape at service construction:
|
||||
* the {@link TOOL_ORDER_REST} rest entry exactly once, no duplicate names.
|
||||
* Returns the list (or undefined when unconfigured); throws otherwise,
|
||||
* failing the service at load — a bad order config must never reach an
|
||||
* assembly. Whether every listed name matches a registered tool is checked
|
||||
* at each assembly instead ({@link orderTools}): tool plugins register after
|
||||
* this service constructs, so the tool set does not exist yet here.
|
||||
*/
|
||||
function validateToolOrder(toolOrder: string[] | undefined): string[] | undefined {
|
||||
if (toolOrder === undefined) return undefined
|
||||
const seen = new Set<string>()
|
||||
for (const name of toolOrder) {
|
||||
if (seen.has(name)) throw new Error(`toolOrder lists "${name}" more than once`)
|
||||
seen.add(name)
|
||||
}
|
||||
if (!seen.has(TOOL_ORDER_REST)) {
|
||||
throw new Error(`toolOrder must contain the "${TOOL_ORDER_REST}" rest entry (where unlisted tools are inserted)`)
|
||||
}
|
||||
return toolOrder
|
||||
}
|
||||
|
||||
/**
|
||||
* Order collected tool schemas by the validated policy: with no configured
|
||||
* list, plain lexicographic name order; with one, listed names take their
|
||||
* listed position and every unlisted tool lands at the
|
||||
* {@link TOOL_ORDER_REST} rest entry in lexicographic name order. A listed
|
||||
* name with no collected tool throws — misconfiguration fails loud, and this
|
||||
* is the earliest moment the registered tool set exists to check against
|
||||
* (tool plugins register after the service constructs, so load time is too
|
||||
* early): the assembly rejects, failing the caller's turn before any model
|
||||
* request. Never drops a tool, and both sorts are stable, so tools sharing a
|
||||
* name keep their collection order.
|
||||
*/
|
||||
function orderTools(tools: ToolSchema[], toolOrder: string[] | undefined): ToolSchema[] {
|
||||
const reserved = tools.find(tool => tool.name === TOOL_ORDER_REST)
|
||||
if (reserved !== undefined) {
|
||||
throw new Error(`tool provider returned reserved tool name "${TOOL_ORDER_REST}" (reserved for toolOrder's rest entry)`)
|
||||
}
|
||||
if (toolOrder === undefined) return tools.sort(compareToolNames)
|
||||
const registered = new Set(tools.map(tool => tool.name))
|
||||
const unknown = toolOrder.filter(name => name !== TOOL_ORDER_REST && !registered.has(name))
|
||||
if (unknown.length > 0) {
|
||||
throw new Error(`toolOrder lists unregistered tool${unknown.length > 1 ? 's' : ''} ${unknown.map(name => `"${name}"`).join(', ')}; registered tools: ${[...registered].sort().join(', ') || '(none)'}`)
|
||||
}
|
||||
const listed = new Set(toolOrder)
|
||||
const rest = tools.filter(tool => !listed.has(tool.name)).sort(compareToolNames)
|
||||
return toolOrder.flatMap(name =>
|
||||
name === TOOL_ORDER_REST ? rest : tools.filter(tool => tool.name === name))
|
||||
}
|
||||
|
||||
/** Lexicographic (code-unit) name comparison — locale-independent, so the order is identical on every machine. */
|
||||
function compareToolNames(a: ToolSchema, b: ToolSchema): number {
|
||||
return a.name < b.name ? -1 : a.name > b.name ? 1 : 0
|
||||
}
|
||||
|
||||
/** Plugin config: the deployment-authored fragment of the system prompt (see {@link Config.persona} for its contract). */
|
||||
export interface Config {
|
||||
/**
|
||||
* The deployment's persona — the ONE deployment-authored fragment of the
|
||||
@@ -124,6 +190,29 @@ export interface Config {
|
||||
* deployment opens with the harness identity alone.
|
||||
*/
|
||||
persona?: string
|
||||
/**
|
||||
* Explicit model-facing tool order, as a list of `ToolSchema.name`s: listed
|
||||
* tools take their listed position, and tools absent from the list are
|
||||
* inserted at the {@link TOOL_ORDER_REST} (`'<unlisted-tools>'`) entry in
|
||||
* lexicographic name order. A configured list must contain the rest entry
|
||||
* exactly once, no duplicate names, and no name without a registered tool —
|
||||
* a misconfigured order blocks work instead of silently reaching a model
|
||||
* request: shape violations throw at load, and an unregistered name rejects
|
||||
* every assembly. `TOOL_ORDER_REST` is reserved for the list marker and may
|
||||
* not be a collected tool name; such a provider output also rejects the
|
||||
* assembly. The single assembly-time validation rejects either failure
|
||||
* before any model request — the earliest moment the registered tool set
|
||||
* exists to check against, since tool plugins register after this service
|
||||
* constructs. When omitted, tools are ordered lexicographically by name.
|
||||
* Applied to the tools
|
||||
* {@link SystemPrompt.assemble} collects, BEFORE the
|
||||
* `system-prompt/assemble` waterfall — like the sections' `order` sort, it
|
||||
* canonicalizes what the registry contributed (registration order is a
|
||||
* plugin-load artifact); a waterfall listener that mutates the tool list
|
||||
* owns the determinism of what it emits. Rationale (and why not per-plugin
|
||||
* weights): docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.md.
|
||||
*/
|
||||
toolOrder?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -138,6 +227,10 @@ export interface Config {
|
||||
* while a `}}` still follows (e.g. `{{{model}}}`, `{{a{b}}`) all throw. A
|
||||
* lone `{{` with no `}}` anywhere after it is ordinary prose and passes
|
||||
* through verbatim. Substituted values are never re-scanned.
|
||||
* @param assembly - the assembly to render (typically the awaited result of
|
||||
* {@link SystemPrompt.assemble}); only `sections` and `variables` are read.
|
||||
* @returns the full system prompt text; `''` when every section renders empty
|
||||
* (the caller then sends no system prompt at all).
|
||||
*/
|
||||
export function renderPrompt(assembly: PromptAssembly): string {
|
||||
return assembly.sections
|
||||
@@ -198,14 +291,23 @@ function interpolate(section: AssembledSection, variables: Record<string, string
|
||||
export class SystemPrompt extends Service {
|
||||
static Config: z<Config> = z.object({
|
||||
persona: z.string().default(''),
|
||||
// A schemastery array defaults to [] when omitted, but an omitted
|
||||
// toolOrder must stay absent ("lexicographic order"), not become an
|
||||
// explicitly-configured empty list (which is invalid — it lacks the
|
||||
// rest entry). Forcing the default to undefined keeps the key out of the
|
||||
// validated config; the cast is needed because .default() expects the
|
||||
// array type.
|
||||
toolOrder: z.array(z.string()).default(undefined as unknown as string[]),
|
||||
})
|
||||
|
||||
private sections: PromptSection[] = []
|
||||
private toolProviders: (() => ToolSchema[])[] = []
|
||||
private variableProviders = new Map<string, (context: AssembleContext) => string | undefined>()
|
||||
private readonly toolOrder: string[] | undefined
|
||||
|
||||
constructor(ctx: Context, public config: Config) {
|
||||
super(ctx, 'systemPrompt')
|
||||
this.toolOrder = validateToolOrder(config.toolOrder)
|
||||
// The harness-owned openers. They live HERE (not on the loop plugin) so a
|
||||
// deployment that swaps in a different loop keeps them: the identity is a
|
||||
// harness fact stated ahead of everything, and the persona is the
|
||||
@@ -261,7 +363,10 @@ export class SystemPrompt extends Service {
|
||||
/**
|
||||
* 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`.
|
||||
* removed when the calling fiber is disposed. A provider must not return a
|
||||
* schema named {@link TOOL_ORDER_REST}; that name is reserved for
|
||||
* {@link Config.toolOrder}'s rest entry and rejects the assembly. Emits
|
||||
* `system-prompt/change`.
|
||||
* @param provider - evaluated at every {@link assemble} for fresh schemas.
|
||||
* @returns the disposer that removes the provider.
|
||||
*/
|
||||
@@ -318,19 +423,28 @@ export class SystemPrompt extends Service {
|
||||
|
||||
/**
|
||||
* Assemble the current prompt for one caller: section texts are resolved
|
||||
* against `context` and sorted by order, tools collected from all
|
||||
* providers, and every registered variable resolved against `context` into
|
||||
* `assembly.variables`. Tool schemas are deep-cloned because adapters and
|
||||
* request waterfalls may mutate schema objects. 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. Interpolation happens later, in {@link renderPrompt}.
|
||||
* against `context` and sorted by order, tools collected from all providers
|
||||
* and put in the canonical model-facing order ({@link Config.toolOrder}, or
|
||||
* lexicographic name order when unconfigured — provider registration order
|
||||
* is a plugin-load artifact and never reaches the assembly; a configured
|
||||
* order naming a tool no provider contributed rejects the assembly), and every
|
||||
* registered variable resolved against `context` into `assembly.variables`.
|
||||
* Tool schemas are deep-cloned because adapters and request waterfalls may
|
||||
* mutate schema objects. Runs through the `system-prompt/assemble`
|
||||
* waterfall, giving listeners the opportunity to mutate or replace the
|
||||
* assembly before it reaches the model — like the sections' `order` sort,
|
||||
* tool canonicalization happens on the initial assembly, and a listener
|
||||
* owns the determinism of whatever it emits. Await the result before
|
||||
* reading the assembly values — waterfall listeners may be async.
|
||||
* Interpolation happens later, in {@link renderPrompt}.
|
||||
* @param context - what this assembly is for (defaults to an empty context;
|
||||
* see {@link AssembleContext}).
|
||||
* @returns the assembly after the waterfall has run.
|
||||
*/
|
||||
assemble(context: AssembleContext = {}): Promise<PromptAssembly> {
|
||||
// async so the misconfigured-toolOrder throw in orderTools surfaces as a
|
||||
// rejection: a Promise-returning method must not throw synchronously
|
||||
// (`assemble().catch(...)` would miss it).
|
||||
async assemble(context: AssembleContext = {}): Promise<PromptAssembly> {
|
||||
const variables: Record<string, string | undefined> = {}
|
||||
for (const [name, provider] of this.variableProviders) {
|
||||
variables[name] = provider(context)
|
||||
@@ -343,8 +457,10 @@ export class SystemPrompt extends Service {
|
||||
text: typeof section.text === 'function' ? section.text(context) : section.text,
|
||||
}))
|
||||
.sort((a, b) => a.order - b.order),
|
||||
tools: this.toolProviders.flatMap(provider =>
|
||||
provider().map(tool => ({ ...tool, parameters: structuredClone(tool.parameters) }))),
|
||||
tools: orderTools(
|
||||
this.toolProviders.flatMap(provider =>
|
||||
provider().map(tool => ({ ...tool, parameters: structuredClone(tool.parameters) }))),
|
||||
this.toolOrder),
|
||||
variables,
|
||||
}
|
||||
return this.ctx.waterfall(this, 'system-prompt/assemble', assembly, context, () => Promise.resolve(assembly))
|
||||
|
||||
115
packages/core/system-prompt/tests/tool-order.spec.ts
Normal file
115
packages/core/system-prompt/tests/tool-order.spec.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import SystemPrompt, { PromptAssembly, TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
|
||||
import type { ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
function tool(name: string, description = name): ToolSchema {
|
||||
return { name, description, parameters: { type: 'object', properties: {} } }
|
||||
}
|
||||
|
||||
async function mount(config: { persona?: string; toolOrder?: string[] } = {}): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt, config)
|
||||
return ctx
|
||||
}
|
||||
|
||||
function names(assembly: PromptAssembly): string[] {
|
||||
return assembly.tools.map(t => t.name)
|
||||
}
|
||||
|
||||
describe('SystemPrompt tool order', () => {
|
||||
// The ONE place the public constant's value is pinned; everything else
|
||||
// (tests and deployment configs alike) references TOOL_ORDER_REST.
|
||||
it('exports the rest entry as "<unlisted-tools>"', () => {
|
||||
expect(TOOL_ORDER_REST).toBe('<unlisted-tools>')
|
||||
})
|
||||
|
||||
it('assembles tools in lexicographic name order when no toolOrder is configured', async () => {
|
||||
const ctx = await mount()
|
||||
ctx.systemPrompt.tools(() => [tool('charlie'), tool('alpha')])
|
||||
ctx.systemPrompt.tools(() => [tool('bravo')])
|
||||
expect(names(await ctx.systemPrompt.assemble())).toEqual(['alpha', 'bravo', 'charlie'])
|
||||
})
|
||||
|
||||
it('assembles the same order regardless of provider registration order', async () => {
|
||||
const forward = await mount()
|
||||
forward.systemPrompt.tools(() => [tool('alpha')])
|
||||
forward.systemPrompt.tools(() => [tool('zulu')])
|
||||
const backward = await mount()
|
||||
backward.systemPrompt.tools(() => [tool('zulu')])
|
||||
backward.systemPrompt.tools(() => [tool('alpha')])
|
||||
expect(names(await forward.systemPrompt.assemble())).toEqual(['alpha', 'zulu'])
|
||||
expect(names(await backward.systemPrompt.assemble())).toEqual(['alpha', 'zulu'])
|
||||
})
|
||||
|
||||
it('applies a configured toolOrder: listed positions, rest at the rest entry lexicographically', async () => {
|
||||
const ctx = await mount({ toolOrder: ['todo_write', TOOL_ORDER_REST, 'bash'] })
|
||||
ctx.systemPrompt.tools(() => [tool('bash'), tool('echo_b'), tool('todo_write'), tool('echo_a')])
|
||||
expect(names(await ctx.systemPrompt.assemble())).toEqual(['todo_write', 'echo_a', 'echo_b', 'bash'])
|
||||
})
|
||||
|
||||
it('rejects the assembly when toolOrder names a tool that is not registered (misconfiguration blocks work)', async () => {
|
||||
const ctx = await mount({ toolOrder: ['todo_write', 'ghost', TOOL_ORDER_REST, 'wraith'] })
|
||||
ctx.systemPrompt.tools(() => [tool('bash'), tool('todo_write')])
|
||||
await expect(ctx.systemPrompt.assemble()).rejects.toThrow(
|
||||
'toolOrder lists unregistered tools "ghost", "wraith"; registered tools: bash, todo_write')
|
||||
})
|
||||
|
||||
it('names the single unregistered tool when no tools are registered at all', async () => {
|
||||
const ctx = await mount({ toolOrder: ['ghost', TOOL_ORDER_REST] })
|
||||
await expect(ctx.systemPrompt.assemble()).rejects.toThrow(
|
||||
'toolOrder lists unregistered tool "ghost"; registered tools: (none)')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['without an explicit toolOrder', undefined],
|
||||
['with only the rest entry configured', [TOOL_ORDER_REST]],
|
||||
])('rejects a provider tool named like the reserved rest entry %s', async (_case, toolOrder) => {
|
||||
const ctx = await mount(toolOrder === undefined ? {} : { toolOrder })
|
||||
ctx.systemPrompt.tools(() => [tool(TOOL_ORDER_REST)])
|
||||
await expect(ctx.systemPrompt.assemble()).rejects.toThrow(
|
||||
`tool provider returned reserved tool name "${TOOL_ORDER_REST}"`)
|
||||
})
|
||||
|
||||
it('keeps collection order between tools that share a name (stable sort)', async () => {
|
||||
const ctx = await mount()
|
||||
ctx.systemPrompt.tools(() => [tool('dup', 'first'), tool('anchor'), tool('dup', 'second')])
|
||||
const assembly = await ctx.systemPrompt.assemble()
|
||||
expect(assembly.tools.map(t => t.description)).toEqual(['anchor', 'first', 'second'])
|
||||
})
|
||||
|
||||
it('canonicalizes BEFORE the assemble waterfall: listeners see the ordered list and own their own edits', async () => {
|
||||
const ctx = await mount()
|
||||
ctx.systemPrompt.tools(() => [tool('zulu'), tool('alpha')])
|
||||
let seen: string[] | undefined
|
||||
ctx.on('system-prompt/assemble', function (assembly, _context, next) {
|
||||
seen = assembly.tools.map(t => t.name)
|
||||
// A listener-appended tool is NOT re-sorted — same contract as sections:
|
||||
// canonicalization applies to what the registry contributed, and a
|
||||
// listener owns the determinism of what it emits.
|
||||
assembly.tools.push(tool('aardvark'))
|
||||
return next()
|
||||
})
|
||||
const assembly = await ctx.systemPrompt.assemble()
|
||||
expect(seen).toEqual(['alpha', 'zulu'])
|
||||
expect(names(assembly)).toEqual(['alpha', 'zulu', 'aardvark'])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['an empty list', []],
|
||||
['a list without the rest entry', ['bash', 'todo_write']],
|
||||
])('rejects %s at load (the rest entry is required)', async (_case, toolOrder) => {
|
||||
await expect(new Context().plugin(SystemPrompt, { toolOrder })).rejects.toThrow(`must contain the "${TOOL_ORDER_REST}" rest entry`)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a duplicate tool name', ['bash', 'bash', TOOL_ORDER_REST]],
|
||||
['a duplicate rest entry', [TOOL_ORDER_REST, 'bash', TOOL_ORDER_REST]],
|
||||
])('rejects %s at load', async (_case, toolOrder) => {
|
||||
await expect(new Context().plugin(SystemPrompt, { toolOrder })).rejects.toThrow('more than once')
|
||||
})
|
||||
|
||||
it('throws from direct construction too', () => {
|
||||
expect(() => new SystemPrompt(new Context(), { toolOrder: ['bash'] })).toThrow('rest entry')
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
# dsh-tools
|
||||
|
||||
Tool registry and execution pipeline. Tool plugins register their schemas and executors; the agent loop executes each call through `tools/pre-execute` (the allow/deny gate) → core dispatch → `tools/post-execute` (inspect/replace the result, attach context).
|
||||
Tool registry and execution pipeline. Tool plugins register their schemas and executors; the agent loop executes each call through `tools/pre-execute` (the allow/deny gate) → `tools/execute` (an around-dispatch wrapper for timeout/retry/metrics plugins) → `tools/post-execute` (inspect/replace the result, attach context).
|
||||
|
||||
## Service: `ToolRegistry` (ctx key: `tools`)
|
||||
|
||||
@@ -8,8 +8,8 @@ Tool registry and execution pipeline. Tool plugins register their schemas and ex
|
||||
|
||||
- `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). The shipped tools' schemas are catalogued in [docs/tool-catalog/tools.md](../../../docs/tool-catalog/tools.md), generated by booting each tool plugin and harvesting this method (see [the tool-schema-catalog RFC](../../../docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md)).
|
||||
- `ctx.tools.execute(exec: ToolExecution): Promise<ToolExecutionResult>` Execute one tool call through the `tools/pre-execute` → dispatch → `tools/post-execute` pipeline.
|
||||
- `ctx.tools.schemas(): ToolSchema[]` Schemas of all registered tools (without the `execute` functions). The shipped tools' schemas are catalogued in [docs/tool-catalog.md](../../../docs/tool-catalog.md), generated by booting each tool plugin and harvesting this method (see [the tool-schema-catalog RFC](../../../docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md)).
|
||||
- `ctx.tools.execute(exec: ToolExecution): Promise<ToolExecutionResult>` Execute one tool call through the `tools/pre-execute` → `tools/execute` → `tools/post-execute` pipeline.
|
||||
|
||||
### Injected services
|
||||
|
||||
@@ -20,12 +20,13 @@ Tool registry and execution pipeline. Tool plugins register their schemas and ex
|
||||
| Event | Mode | Purpose |
|
||||
|---|---|---|
|
||||
| `tools/pre-execute` | waterfall | Allow/deny gate BEFORE a tool runs (sandbox, permission, hooks); returns `PreToolDecision` |
|
||||
| `tools/execute` | waterfall | Around-dispatch wrapper (timeout, retry, metrics): `(exec, next)` → the dispatched `ToolExecutionResult`; `next()` is dispatch-with-normalization |
|
||||
| `tools/post-execute` | waterfall | Inspect/replace the result AFTER a tool runs, attach context; returns `PostToolDecision` |
|
||||
| `tools/change` | emit | A tool was registered or unregistered |
|
||||
|
||||
### Key types
|
||||
|
||||
- `ToolDefinition` — `ToolSchema` + `execute(args, exec): Promise<ContentBlock[] | { content: ContentBlock[]; meta? }>` (the bare array is the model-facing content; the object form additionally attaches an opaque, JSON-serializable `meta` presentation payload persisted on the `tool/result` event and handed back to `presentResult`), plus optional `presentCall(args)` / `presentResult(args, result)` for tool-owned UI presentation (see below).
|
||||
- `ToolDefinition` — `ToolSchema` + `execute(args, exec): Promise<ContentBlock[] | { content: ContentBlock[]; meta? }>` (the bare array is the model-facing content; the object form additionally attaches an opaque, JSON-serializable `meta` presentation payload persisted on the `tool/result` event and handed back to `presentResult`), plus optional `presentCall(args)` / `presentResult(args, result)` for tool-owned UI presentation (see below). It also carries an optional cooperative timeout budget `timeoutMs?: number` (ms) enforced by `@deepseek-ai/dsh-timeout-policy`, never sent to the model.
|
||||
- `ToolExecution` — one pending tool call: `{ callId, name, arguments, agent?, signal? }`.
|
||||
- `ToolExecutionResult` — outcome: `{ callId, content, isError, error?, additionalContext?, meta? }`. On failure with a `HarnessError`, `error: { name, code }` carries the structured failure class alongside the model-facing text (the loop forwards it onto the `tool/result` session event for retry/sandbox plugins and replay). `additionalContext` (a `HookContext`) ferries any `tools/post-execute` context up to the loop, which buffers it and appends it as a `context/message` after all `tool/result`s in the step. `meta` is the tool's opaque presentation payload from a successful `execute` (the object return form); the loop forwards it onto the `tool/result` session event for result-card rendering.
|
||||
- `PreToolDecision` — `{kind:'allow'}` | `{kind:'deny', reason}` | `{kind:'ask', reason?}`. Input rewrite (changing `arguments`) is deliberately NOT offered (it would desync the pre-execution audit/history/UI from what ran — its own proposed RFC); `ask` degrades to `deny` until the permission system lands.
|
||||
@@ -35,7 +36,7 @@ Tool registry and execution pipeline. Tool plugins register their schemas and ex
|
||||
### Extension points
|
||||
|
||||
- Tool plugins call `ctx.tools.register()` — schemas flow into the assembly automatically.
|
||||
- `tools/pre-execute` is the allow/deny gate (sandbox, permission, hooks): listeners receive `(exec, next)` and call `next()` to delegate to the default (allow) or return a `PreToolDecision` to short-circuit; a `deny`/`ask` skips dispatch and yields an `isError` result. `tools/post-execute` is the inspect/transform seam: `(exec, result, next)` → a `PostToolDecision` that can replace content, block with feedback, or attach `additionalContext`. Core dispatch sits between them as plain code; the tool body keeps its own try/catch so a thrown tool still reaches `post-execute` as an `isError`. Both follow the typed-Decision idiom shared with the `agent/*` interception seams (see [`dsh-agent`](../agent/README.md)).
|
||||
- `tools/pre-execute` is the allow/deny gate (sandbox, permission, hooks): listeners receive `(exec, next)` and call `next()` to delegate to the default (allow) or return a `PreToolDecision` to short-circuit; a `deny`/`ask` skips dispatch and yields an `isError` result. `tools/execute` is the around-dispatch seam (timeout, retry, metrics): listeners receive `(exec, next)` and call `next()` to delegate to core dispatch (returning its `ToolExecutionResult`, optionally wrapped), or return a replacement result to short-circuit dispatch; the base `next()` IS dispatch-with-normalization, so `await next()` already yields an `isError` result for a thrown/unknown tool (never a raw throw). A wrapper mutates `exec` in place before `next()` — e.g. replacing `exec.signal` with a per-call deadline — because cordis `next()` ignores passed arguments. `tools/post-execute` is the inspect/transform seam: `(exec, result, next)` → a `PostToolDecision` that can replace content, block with feedback, or attach `additionalContext`. Core dispatch is the base of the `tools/execute` waterfall; the tool body keeps its own try/catch so a thrown tool still reaches `post-execute` as an `isError`. All follow the typed-Decision idiom shared with the `agent/*` interception seams (see [`dsh-agent`](../agent/README.md)); `@deepseek-ai/dsh-timeout-policy` is the reference `tools/execute` wrapper.
|
||||
- MCP servers: one plugin per server, discover tools, call `ctx.tools.register()` with the server's schemas.
|
||||
|
||||
### Typed tool parameter schemas
|
||||
@@ -71,6 +72,14 @@ A `defineTool` tool also **validates the model-generated arguments against its `
|
||||
|
||||
See `defineTool`, `validateArgs`, `ToolArgsError`, `SchemaSpec`, `InferArgs`, and `schemaSpecToJsonSchema` in the public API for details.
|
||||
|
||||
`defineTool` also validates an optional `timeoutMs` at definition time when present: it must be a positive finite number, or the helper throws — the budget is attached to the produced `ToolDefinition` (for `@deepseek-ai/dsh-timeout-policy`) and never reaches the model.
|
||||
|
||||
### Structured-output schema subset
|
||||
|
||||
A separate vocabulary for callers that DEMAND a machine-readable value from an agent — the subagent seam's `SubagentStartRequest.outputSchema` (and, by extension, a workflow's `agent({ schema })`). Unlike `SchemaSpec` (the author-facing DSL for tool parameters), a `StructuredOutputSchema` is an object-rooted **raw JSON Schema subset** as data: it travels verbatim to the model as a forced tool's `parameters`, and the produced value is validated against it.
|
||||
|
||||
The subset is deliberately narrow and REJECTS LOUD outside it — accepting a keyword the validator doesn't enforce would validate less than the schema promises (accepted-then-ignored). Supported: single-string `type` (`object`/`array`/`string`/`number`/`integer`/`boolean`/`null`; type arrays rejected), `properties`/`required`/`additionalProperties` (boolean; every `required` key must be declared), `items`, scalar-only `enum`/`const`; annotations (`description`/`title`/`default`/`examples`) are ignored but must still be JSON data. `assertSupportedOutputSchema(schema)` throws `OutputSchemaError` (`code: 'UNSUPPORTED_SCHEMA'`, listing every violation) for anything else; `validateStructuredValue(schema, value)` returns path-qualified violations (empty = valid, total — never throws).
|
||||
|
||||
### Tool-owned UI presentation
|
||||
|
||||
A tool owns how ITS calls render in a UI (an editor's tool-call card, a CLI log line) — a UI plugin must NOT special-case tool names. A `ToolDefinition` may declare two optional, pure, display-only methods that return a **`card`-tagged render intent** (a discriminated union — a tool declares its card kind once and a UI bridge switches on `card`):
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
/**
|
||||
* Tool registry and execution pipeline. Plugins register tools; the registry
|
||||
* feeds schemas into the system prompt, and `execute()` dispatches each call
|
||||
* through `tools/pre-execute` (the allow/deny gate) → core dispatch →
|
||||
* `tools/post-execute` (inspect/replace the result, attach context) for
|
||||
* sandbox, permission, and hook plugins to gate or transform a call.
|
||||
* through `tools/pre-execute` (the allow/deny gate) → `tools/execute` (an
|
||||
* around-dispatch wrapper for timeout/retry/metrics plugins) → `tools/post-execute`
|
||||
* (inspect/replace the result, attach context) for sandbox, permission, and hook
|
||||
* plugins to gate or transform a call.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tools
|
||||
*/
|
||||
@@ -28,6 +29,16 @@ export {
|
||||
type JsonSchemaObject,
|
||||
} from './schema.ts'
|
||||
|
||||
export {
|
||||
assertSupportedOutputSchema,
|
||||
validateStructuredValue,
|
||||
OutputSchemaError,
|
||||
type StructuredOutputSchema,
|
||||
type StructuredSchemaNode,
|
||||
type StructuredSchemaType,
|
||||
type StructuredScalar,
|
||||
} from './json-schema.ts'
|
||||
|
||||
// The render-intent vocabulary a tool declares via `presentCall`/`presentResult`
|
||||
// lives in its own UI-facing module; re-export it so `@deepseek-ai/dsh-tools`
|
||||
// stays the single public surface for consumers (producers + the ACP bridge).
|
||||
@@ -64,17 +75,37 @@ declare module 'cordis' {
|
||||
* @mode waterfall
|
||||
*/
|
||||
'tools/pre-execute'(this: ToolRegistry, exec: ToolExecution, next: () => Promise<PreToolDecision>): Promise<PreToolDecision>
|
||||
/**
|
||||
* Around-dispatch waterfall wrapping the registry's core tool dispatch,
|
||||
* between the `tools/pre-execute` gate and the `tools/post-execute` seam. A
|
||||
* listener receives `(exec, next)`: call `next()` to delegate to dispatch
|
||||
* (returning its {@link ToolExecutionResult}, optionally wrapped), or return a
|
||||
* replacement result without calling `next()` to short-circuit dispatch. The
|
||||
* base `next()` IS the dispatch-with-normalization thunk — a thrown tool (or
|
||||
* unknown tool) is already normalized to an `isError` result by the time a
|
||||
* listener's `await next()` returns, so a wrapper never sees a raw throw from
|
||||
* the tool body. This is the seam a timeout/retry/metrics plugin wraps: it can
|
||||
* mutate `exec` (e.g. replace `exec.signal` with a per-call deadline) BEFORE
|
||||
* `next()` and inspect the result AFTER. (Cordis `next()` ignores any passed
|
||||
* arguments and re-invokes downstream with the shared payload, so a wrapper
|
||||
* mutates `exec` in place rather than passing a new object to `next()`.)
|
||||
* Multiple listeners compose by registration order — an outer one wraps the
|
||||
* inner ones plus dispatch.
|
||||
* @param exec - the allowed call about to dispatch (name, parsed arguments, caller agent, signal).
|
||||
* @mode waterfall
|
||||
*/
|
||||
'tools/execute'(this: ToolRegistry, exec: ToolExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult>
|
||||
/**
|
||||
* Waterfall AFTER a tool runs — where hook plugins inspect the result and
|
||||
* accept it (optionally REPLACING the model-facing content, and/or attaching
|
||||
* `additionalContext` for the next request) or block it with corrective
|
||||
* `feedback` (Claude Code's `PostToolUse`). Listeners receive
|
||||
* `(exec, result, next)`: call `next()` to delegate to the default (accept
|
||||
* unchanged), or return a {@link PostToolDecision} to override. The core tool
|
||||
* dispatch sits between the two waterfalls as plain code, all inside
|
||||
* `execute`'s outer try/catch (and the tool body keeps its own inner
|
||||
* try/catch, so a thrown tool still reaches `post-execute` as an `isError`
|
||||
* result).
|
||||
* unchanged), or return a {@link PostToolDecision} to override. Core tool
|
||||
* dispatch runs earlier as the base `next()` of the `tools/execute`
|
||||
* waterfall, all inside `execute`'s outer try/catch (and the tool body keeps
|
||||
* its own inner try/catch, so a thrown tool still reaches `post-execute` as an
|
||||
* `isError` result).
|
||||
* @param exec - the call that just ran (name, parsed arguments, caller agent).
|
||||
* @param result - the dispatch outcome a listener may accept, replace, or block.
|
||||
* @mode waterfall
|
||||
@@ -107,6 +138,14 @@ export type ToolExecuteReturn = ContentBlock[] | { content: ContentBlock[]; meta
|
||||
/** A registered tool: its schema plus the execution function. */
|
||||
export interface ToolDefinition extends ToolSchema {
|
||||
execute(args: unknown, exec: ToolExecution): Promise<ToolExecuteReturn>
|
||||
/**
|
||||
* Cooperative tool-call timeout budget in milliseconds. Omit for no deadline.
|
||||
* Enforced by `@deepseek-ai/dsh-timeout-policy` (a `tools/execute` wrapper); it
|
||||
* is NEVER sent to the model — `schemas()` whitelists only name/description/
|
||||
* parameters. Declaring it asserts this tool forwards `exec.signal` to a
|
||||
* cooperative implementation that can reach quiescence when the signal aborts.
|
||||
*/
|
||||
timeoutMs?: number
|
||||
/**
|
||||
* Optional: how to present the PENDING state of one call in a UI, derived from
|
||||
* the call's `args` (parsed arguments, `unknown` — the tool validates/narrows
|
||||
@@ -261,7 +300,7 @@ function errorInfo(error: unknown): ToolErrorInfo | undefined {
|
||||
|
||||
/**
|
||||
* Tool registry (`ctx.tools`): tool plugins register definitions; the agent
|
||||
* loop executes calls through the `tools/pre-execute` → dispatch →
|
||||
* loop executes calls through the `tools/pre-execute` → `tools/execute` →
|
||||
* `tools/post-execute` pipeline. The registry contributes its schemas into the
|
||||
* system-prompt assembly.
|
||||
*/
|
||||
@@ -335,18 +374,20 @@ export class ToolRegistry extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute one tool call through the `tools/pre-execute` → dispatch →
|
||||
* `tools/post-execute` pipeline. The two waterfalls are the gate (allow/deny)
|
||||
* and the inspect/transform seam; core dispatch sits between them as plain
|
||||
* code. The whole thing is wrapped in one outer try/catch so a throwing
|
||||
* listener (in either waterfall) becomes an `isError` result instead of
|
||||
* failing the turn; the tool body ALSO keeps its own inner try/catch, so a
|
||||
* thrown tool becomes an `isError` result that `post-execute` listeners can
|
||||
* still inspect. If the tool is not registered, the result is an `isError`
|
||||
* carrying a `UNKNOWN_TOOL` structured error. A thrown {@link HarnessError}
|
||||
* surfaces its `{ name, code }` on the result.
|
||||
* Execute one tool call through the `tools/pre-execute` → `tools/execute`
|
||||
* (around dispatch) → `tools/post-execute` pipeline. `pre-execute` is the gate
|
||||
* (allow/deny), `tools/execute` wraps core dispatch (a timeout/retry/metrics
|
||||
* seam), and `post-execute` is the inspect/transform seam; core dispatch sits
|
||||
* as the base `next()` of the `tools/execute` waterfall. The whole thing is
|
||||
* wrapped in one outer try/catch so a throwing listener (in any waterfall)
|
||||
* becomes an `isError` result instead of failing the turn; the tool body ALSO
|
||||
* keeps its own inner try/catch, so a thrown tool becomes an `isError` result
|
||||
* that `tools/execute` and `post-execute` listeners can still inspect. If the
|
||||
* tool is not registered, the result is an `isError` carrying a `UNKNOWN_TOOL`
|
||||
* structured error. A thrown {@link HarnessError} surfaces its `{ name, code }`
|
||||
* on the result.
|
||||
* @param exec - the call to run (name, parsed arguments, caller agent, signal).
|
||||
* @returns the final result after both waterfalls; failures resolve as
|
||||
* @returns the final result after every waterfall; failures resolve as
|
||||
* `isError` results, never rejections.
|
||||
*/
|
||||
async execute(exec: ToolExecution): Promise<ToolExecutionResult> {
|
||||
@@ -372,23 +413,30 @@ export class ToolRegistry extends Service {
|
||||
return await this.postExecute(exec, denied)
|
||||
}
|
||||
|
||||
// --- Core dispatch (plain code between the waterfalls). The tool body's
|
||||
// own try/catch turns a throw into an isError result so post-execute can
|
||||
// inspect it; an unknown tool routes through the same catch. ---
|
||||
let result: ToolExecutionResult
|
||||
try {
|
||||
const tool = this.store.get(exec.name)
|
||||
if (!tool) throw new ToolNotFoundError(exec.name)
|
||||
// Normalize the two `execute` return shapes: a bare ContentBlock[] (no
|
||||
// meta) or a { content, meta } object (a tool attaching a private
|
||||
// presentation payload). An array IS the content; the object carries it.
|
||||
const returned = await tool.execute(exec.arguments, exec)
|
||||
const content = Array.isArray(returned) ? returned : returned.content
|
||||
const meta = Array.isArray(returned) ? undefined : returned.meta
|
||||
result = { callId: exec.callId, content, isError: false, ...meta !== undefined ? { meta } : {} }
|
||||
} catch (error: unknown) {
|
||||
result = toolErrorResult(exec.callId, error)
|
||||
}
|
||||
// --- Around-dispatch: tools/execute. The base `next` is the dispatch-
|
||||
// with-normalization thunk — the tool body's own try/catch turns a throw
|
||||
// into an isError result so a wrapper (and post-execute) can inspect it;
|
||||
// an unknown tool routes through the same catch. A `tools/execute` listener
|
||||
// (e.g. a timeout plugin) wraps this thunk: it may mutate `exec` before
|
||||
// delegating and inspect the normalized result after. ---
|
||||
const result = await this.ctx.waterfall(
|
||||
this, 'tools/execute', exec,
|
||||
async (): Promise<ToolExecutionResult> => {
|
||||
try {
|
||||
const tool = this.store.get(exec.name)
|
||||
if (!tool) throw new ToolNotFoundError(exec.name)
|
||||
// Normalize the two `execute` return shapes: a bare ContentBlock[] (no
|
||||
// meta) or a { content, meta } object (a tool attaching a private
|
||||
// presentation payload). An array IS the content; the object carries it.
|
||||
const returned = await tool.execute(exec.arguments, exec)
|
||||
const content = Array.isArray(returned) ? returned : returned.content
|
||||
const meta = Array.isArray(returned) ? undefined : returned.meta
|
||||
return { callId: exec.callId, content, isError: false, ...meta !== undefined ? { meta } : {} }
|
||||
} catch (error: unknown) {
|
||||
return toolErrorResult(exec.callId, error)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
return await this.postExecute(exec, result)
|
||||
} catch (error: unknown) {
|
||||
|
||||
345
packages/core/tools/src/json-schema.ts
Normal file
345
packages/core/tools/src/json-schema.ts
Normal file
@@ -0,0 +1,345 @@
|
||||
/**
|
||||
* Structured-output JSON Schema subset: the vocabulary a caller uses to demand
|
||||
* a machine-readable result from a subagent (`SubagentStartRequest.outputSchema`)
|
||||
* or a workflow `agent()` call.
|
||||
*
|
||||
* This is deliberately NOT full JSON Schema. The schema travels verbatim to the
|
||||
* model as a forced tool's `parameters`, and the value the model produces is
|
||||
* validated here — so every accepted keyword must be one this module actually
|
||||
* enforces. Accepting a keyword we don't enforce would validate less than the
|
||||
* schema promises (accepted-then-ignored), so anything outside the subset is
|
||||
* REJECTED LOUD by {@link assertSupportedOutputSchema} instead. The subset:
|
||||
*
|
||||
* - `type` — a single string (`object`/`array`/`string`/`number`/`integer`/
|
||||
* `boolean`/`null`); type ARRAYS (`["string","null"]`) are rejected.
|
||||
* - `properties`/`required`/`additionalProperties` (boolean) on objects; every
|
||||
* `required` key must be declared in `properties`. `additionalProperties`
|
||||
* absent keeps standard JSON Schema semantics (extra keys allowed).
|
||||
* - `items` on arrays (absent ⇒ any JSON items).
|
||||
* - `enum` (non-empty, scalars only) and `const` (scalar) on scalar types.
|
||||
* - Annotations `description`/`title`/`default`/`examples` are allowed and
|
||||
* ignored (they constrain nothing), except that they must still be JSON data
|
||||
* — the schema is serialized onto the wire, so a non-JSON annotation would be
|
||||
* silently mangled.
|
||||
*
|
||||
* Values checked by {@link validateStructuredValue} are expected to be plain
|
||||
* host-realm JSON data (model tool-call arguments are parsed wire JSON; a
|
||||
* caller holding foreign-realm data materializes it first).
|
||||
*
|
||||
* @module dsh-tools/json-schema
|
||||
*/
|
||||
|
||||
import { assertNever, HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
/** The scalar values `enum`/`const` may carry (finite numbers only). */
|
||||
export type StructuredScalar = string | number | boolean | null
|
||||
|
||||
/** The `type` keywords the subset accepts. */
|
||||
export type StructuredSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null'
|
||||
|
||||
/**
|
||||
* One node of the structured-output schema subset. Recursive via `properties`
|
||||
* and `items`; see the module doc for the exact keyword semantics.
|
||||
*/
|
||||
export interface StructuredSchemaNode {
|
||||
type: StructuredSchemaType
|
||||
/** Nested property schemas (`type: 'object'` only). */
|
||||
properties?: Record<string, StructuredSchemaNode>
|
||||
/** Required property names; each must appear in `properties`. */
|
||||
required?: string[]
|
||||
/** `false` rejects undeclared keys; absent/`true` allows them (JSON Schema default). */
|
||||
additionalProperties?: boolean
|
||||
/** Item schema (`type: 'array'` only); absent ⇒ any JSON items. */
|
||||
items?: StructuredSchemaNode
|
||||
/** Allowed values (scalar types only). */
|
||||
enum?: StructuredScalar[]
|
||||
/** The single allowed value (scalar types only). */
|
||||
const?: StructuredScalar
|
||||
/** Annotation, ignored for validation. */
|
||||
description?: string
|
||||
/** Annotation, ignored for validation. */
|
||||
title?: string
|
||||
/** Annotation, ignored for validation (must still be JSON data). */
|
||||
default?: unknown
|
||||
/** Annotation, ignored for validation (must still be JSON data). */
|
||||
examples?: unknown
|
||||
}
|
||||
|
||||
/** A structured-output schema: an OBJECT-rooted {@link StructuredSchemaNode}. */
|
||||
export type StructuredOutputSchema = StructuredSchemaNode & { type: 'object' }
|
||||
|
||||
/**
|
||||
* Thrown by {@link assertSupportedOutputSchema} when a schema falls outside the
|
||||
* supported subset. Extends {@link HarnessError} (`code: 'UNSUPPORTED_SCHEMA'`)
|
||||
* so seam code and tool results can route on it; `violations` lists every
|
||||
* offending path, not just the first.
|
||||
*/
|
||||
export class OutputSchemaError extends HarnessError {
|
||||
/** The individual violation messages, in walk order. */
|
||||
readonly violations: string[]
|
||||
|
||||
constructor(violations: string[]) {
|
||||
super(`unsupported output schema: ${violations.join('; ')}`, 'UNSUPPORTED_SCHEMA')
|
||||
this.name = 'OutputSchemaError'
|
||||
this.violations = violations
|
||||
}
|
||||
}
|
||||
|
||||
/** The keywords the subset accepts, checked (`constraint`) or ignored (`annotation`). */
|
||||
const CONSTRAINT_KEYWORDS = new Set(['type', 'properties', 'required', 'additionalProperties', 'items', 'enum', 'const'])
|
||||
const ANNOTATION_KEYWORDS = new Set(['description', 'title', 'default', 'examples'])
|
||||
|
||||
const SCHEMA_TYPES: readonly StructuredSchemaType[] = ['object', 'array', 'string', 'number', 'integer', 'boolean', 'null']
|
||||
|
||||
/**
|
||||
* Whether a value is a PLAIN JSON object — non-null, non-array, and with a
|
||||
* prototype chain of at most one link (`null`-proto, or any realm's
|
||||
* `Object.prototype`, whose own prototype is `null`). Realm-agnostic on
|
||||
* purpose: a schema materialized in another realm carries THAT realm's
|
||||
* `Object.prototype`, which an identity check would wrongly reject. Exotic
|
||||
* hosts (`Date`, `Map`, class instances) have longer chains and are rejected —
|
||||
* they would serialize lossily (`Date` → string, `Map` → `{}`) instead of
|
||||
* failing loud.
|
||||
*/
|
||||
function isObjectLike(value: unknown): value is Record<string, unknown> {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
|
||||
const proto: unknown = Object.getPrototypeOf(value)
|
||||
return proto === null || Object.getPrototypeOf(proto) === null
|
||||
}
|
||||
|
||||
/** Whether a value is a supported scalar (`enum`/`const` member): string, finite number, boolean, or null. */
|
||||
function isStructuredScalar(value: unknown): value is StructuredScalar {
|
||||
return value === null || typeof value === 'string' || typeof value === 'boolean'
|
||||
|| (typeof value === 'number' && Number.isFinite(value))
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a value is JSON data (annotation payloads only): scalars, arrays, and
|
||||
* object-likes of such values. Realm-agnostic on purpose (no prototype check) —
|
||||
* the schema may have been materialized from another realm; structural JSON-ness
|
||||
* is what the wire needs. Cycles are rejected via `seen`.
|
||||
*/
|
||||
function isJsonData(value: unknown, seen: Set<object>): boolean {
|
||||
if (isStructuredScalar(value)) return true
|
||||
// The scalar check above already returned for null, so `object` here is a real object.
|
||||
if (typeof value !== 'object') return false
|
||||
if (seen.has(value)) return false
|
||||
seen.add(value)
|
||||
try {
|
||||
if (Array.isArray(value)) return value.every(entry => isJsonData(entry, seen))
|
||||
// A non-plain object (Date, Map, class instance) is NOT JSON data even when
|
||||
// it has no enumerable values — it would serialize lossily, not loudly.
|
||||
if (!isObjectLike(value)) return false
|
||||
return Object.values(value).every(entry => isJsonData(entry, seen))
|
||||
} finally {
|
||||
seen.delete(value)
|
||||
}
|
||||
}
|
||||
|
||||
/** Collect subset violations for one schema node (recursive walk). */
|
||||
function checkSchemaNode(node: unknown, path: string, violations: string[], seen: Set<object>): void {
|
||||
if (!isObjectLike(node)) {
|
||||
violations.push(`${path} must be a schema object`)
|
||||
return
|
||||
}
|
||||
if (seen.has(node)) {
|
||||
violations.push(`${path} is circular`)
|
||||
return
|
||||
}
|
||||
seen.add(node)
|
||||
|
||||
for (const key of Object.keys(node)) {
|
||||
if (CONSTRAINT_KEYWORDS.has(key)) continue
|
||||
if (ANNOTATION_KEYWORDS.has(key)) {
|
||||
if (!isJsonData(node[key], new Set())) violations.push(`${path}.${key} annotation must be JSON data`)
|
||||
continue
|
||||
}
|
||||
violations.push(`${path}.${key} is not a supported keyword (subset: type/properties/required/additionalProperties/items/enum/const + annotations)`)
|
||||
}
|
||||
if (typeof node.description !== 'undefined' && typeof node.description !== 'string') {
|
||||
violations.push(`${path}.description must be a string`)
|
||||
}
|
||||
if (typeof node.title !== 'undefined' && typeof node.title !== 'string') {
|
||||
violations.push(`${path}.title must be a string`)
|
||||
}
|
||||
|
||||
const type = node.type
|
||||
if (typeof type !== 'string' || !(SCHEMA_TYPES as readonly unknown[]).includes(type)) {
|
||||
violations.push(Array.isArray(type)
|
||||
? `${path}.type must be a single type string (type arrays are not supported)`
|
||||
: `${path}.type must be one of ${SCHEMA_TYPES.join('/')}`)
|
||||
seen.delete(node)
|
||||
return
|
||||
}
|
||||
const schemaType = type as StructuredSchemaType
|
||||
|
||||
// Keywords that only make sense on one type are rejected elsewhere — an
|
||||
// `items` on an object (or `properties` on a string) is a schema-author bug
|
||||
// the subset surfaces rather than ignores.
|
||||
const allowedFor: Record<string, StructuredSchemaType[]> = {
|
||||
properties: ['object'],
|
||||
required: ['object'],
|
||||
additionalProperties: ['object'],
|
||||
items: ['array'],
|
||||
enum: ['string', 'number', 'integer', 'boolean', 'null'],
|
||||
const: ['string', 'number', 'integer', 'boolean', 'null'],
|
||||
}
|
||||
for (const [key, types] of Object.entries(allowedFor)) {
|
||||
if (key in node && !types.includes(schemaType)) {
|
||||
violations.push(`${path}.${key} is not supported on type "${schemaType}"`)
|
||||
}
|
||||
}
|
||||
|
||||
switch (schemaType) {
|
||||
case 'object': {
|
||||
const properties = node.properties
|
||||
if (properties !== undefined) {
|
||||
if (!isObjectLike(properties)) {
|
||||
violations.push(`${path}.properties must be an object of schemas`)
|
||||
} else {
|
||||
for (const [key, child] of Object.entries(properties)) {
|
||||
checkSchemaNode(child, `${path}.properties.${key}`, violations, seen)
|
||||
}
|
||||
}
|
||||
}
|
||||
const required = node.required
|
||||
if (required !== undefined) {
|
||||
if (!Array.isArray(required) || required.some(entry => typeof entry !== 'string')) {
|
||||
violations.push(`${path}.required must be an array of strings`)
|
||||
} else {
|
||||
const declared = isObjectLike(properties) ? properties : {}
|
||||
// The guard above proved every entry is a string.
|
||||
for (const key of required as string[]) {
|
||||
// Own-property check: `in` would let inherited names (`toString`)
|
||||
// satisfy the declared-in-properties contract via the prototype.
|
||||
if (!Object.hasOwn(declared, key)) violations.push(`${path}.required names "${key}" which is not in properties`)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (node.additionalProperties !== undefined && typeof node.additionalProperties !== 'boolean') {
|
||||
violations.push(`${path}.additionalProperties must be a boolean`)
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'array': {
|
||||
if (node.items !== undefined) checkSchemaNode(node.items, `${path}.items`, violations, seen)
|
||||
break
|
||||
}
|
||||
case 'string':
|
||||
case 'number':
|
||||
case 'integer':
|
||||
case 'boolean':
|
||||
case 'null': {
|
||||
const allowed = node.enum
|
||||
if (allowed !== undefined) {
|
||||
if (!Array.isArray(allowed) || allowed.length === 0 || !allowed.every(entry => isStructuredScalar(entry))) {
|
||||
violations.push(`${path}.enum must be a non-empty array of scalars`)
|
||||
}
|
||||
}
|
||||
if ('const' in node && !isStructuredScalar(node.const)) {
|
||||
violations.push(`${path}.const must be a scalar`)
|
||||
}
|
||||
break
|
||||
}
|
||||
/* v8 ignore start -- defensive: schemaType was membership-checked against SCHEMA_TYPES above, so no runtime value reaches here */
|
||||
default:
|
||||
assertNever(schemaType, 'assertSupportedOutputSchema')
|
||||
/* v8 ignore stop */
|
||||
}
|
||||
|
||||
seen.delete(node)
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert `schema` is a supported {@link StructuredOutputSchema} — object-rooted
|
||||
* and entirely within the enforced subset. Throws {@link OutputSchemaError}
|
||||
* (`UNSUPPORTED_SCHEMA`) listing EVERY violation; returns (and narrows) on
|
||||
* success. Call this at the seam boundary, before any child is created.
|
||||
* @param schema - the caller-supplied schema (unknown until asserted).
|
||||
* @returns nothing — the assertion signature narrows `schema` to
|
||||
* {@link StructuredOutputSchema} in the caller's scope on normal return.
|
||||
*/
|
||||
export function assertSupportedOutputSchema(schema: unknown): asserts schema is StructuredOutputSchema {
|
||||
const violations: string[] = []
|
||||
checkSchemaNode(schema, 'schema', violations, new Set())
|
||||
if (violations.length === 0 && (schema as StructuredSchemaNode).type !== 'object') {
|
||||
violations.push('schema.type must be "object" (structured output is object-rooted)')
|
||||
}
|
||||
if (violations.length > 0) throw new OutputSchemaError(violations)
|
||||
}
|
||||
|
||||
/** Collect violations for one value against an (already asserted) schema node. */
|
||||
function checkValue(node: StructuredSchemaNode, value: unknown, path: string): string[] {
|
||||
switch (node.type) {
|
||||
case 'object': {
|
||||
if (!isObjectLike(value)) return [`"${path}" must be an object`]
|
||||
const violations: string[] = []
|
||||
const properties = node.properties ?? {}
|
||||
// Own-property discipline throughout: JSON carries own enumerable
|
||||
// properties only, so an inherited `toString` must not satisfy
|
||||
// `required`, dodge `additionalProperties: false`, or be validated as if
|
||||
// the value carried it.
|
||||
for (const key of node.required ?? []) {
|
||||
if (!Object.hasOwn(value, key) || value[key] === undefined) violations.push(`missing required property "${path}.${key}"`)
|
||||
}
|
||||
for (const [key, child] of Object.entries(properties)) {
|
||||
if (!Object.hasOwn(value, key) || value[key] === undefined) continue
|
||||
violations.push(...checkValue(child, value[key], `${path}.${key}`))
|
||||
}
|
||||
if (node.additionalProperties === false) {
|
||||
for (const key of Object.keys(value)) {
|
||||
if (!Object.hasOwn(properties, key)) violations.push(`"${path}.${key}" is not a declared property (additionalProperties: false)`)
|
||||
}
|
||||
}
|
||||
return violations
|
||||
}
|
||||
case 'array': {
|
||||
if (!Array.isArray(value)) return [`"${path}" must be an array`]
|
||||
if (!node.items) return []
|
||||
const items = node.items
|
||||
return value.flatMap((entry, index) => checkValue(items, entry, `${path}[${index}]`))
|
||||
}
|
||||
case 'string': {
|
||||
if (typeof value !== 'string') return [`"${path}" must be a string`]
|
||||
break
|
||||
}
|
||||
case 'number': {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return [`"${path}" must be a finite number`]
|
||||
break
|
||||
}
|
||||
case 'integer': {
|
||||
if (typeof value !== 'number' || !Number.isInteger(value)) return [`"${path}" must be an integer`]
|
||||
break
|
||||
}
|
||||
case 'boolean': {
|
||||
if (typeof value !== 'boolean') return [`"${path}" must be a boolean`]
|
||||
break
|
||||
}
|
||||
case 'null': {
|
||||
if (value !== null) return [`"${path}" must be null`]
|
||||
break
|
||||
}
|
||||
default:
|
||||
return assertNever(node.type, 'validateStructuredValue')
|
||||
}
|
||||
// Scalar constraint checks, shared by every scalar branch above.
|
||||
if (node.enum && !node.enum.includes(value)) {
|
||||
return [`"${path}" must be one of ${JSON.stringify(node.enum)}`]
|
||||
}
|
||||
if ('const' in node && value !== node.const) {
|
||||
return [`"${path}" must be ${JSON.stringify(node.const)}`]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a value against an (already {@link assertSupportedOutputSchema}-
|
||||
* asserted) schema. Returns human-readable, path-qualified violation messages
|
||||
* — empty means valid. Total: never throws, however malformed the value.
|
||||
* @param schema - the asserted schema to check against.
|
||||
* @param value - the candidate value (e.g. parsed tool-call arguments).
|
||||
* @returns every violation found, in walk order (empty = valid).
|
||||
*/
|
||||
export function validateStructuredValue(schema: StructuredOutputSchema, value: unknown): string[] {
|
||||
return checkValue(schema, value, 'value')
|
||||
}
|
||||
@@ -155,6 +155,9 @@ export interface JsonSchemaObject {
|
||||
* `properties`, `required` array).
|
||||
*
|
||||
* This is a plain function — no schemastery or other framework dependency.
|
||||
* @param spec - the author-facing per-property schema to convert.
|
||||
* @returns the wire-format JSON Schema; the top-level `required` array is
|
||||
* omitted entirely when no property is marked required.
|
||||
*/
|
||||
export function schemaSpecToJsonSchema(spec: SchemaSpec): JsonSchemaObject {
|
||||
const properties: Record<string, unknown> = {}
|
||||
@@ -269,6 +272,9 @@ function checkSpec(spec: SchemaSpec, value: unknown, path: string): string[] {
|
||||
* keys are allowed (no `additionalProperties: false`); `default` is not
|
||||
* applied; an `object`/`array` prop without `properties`/`items` only
|
||||
* type-checks; `enum` is membership (strings only).
|
||||
* @param spec - the declared parameter schema to validate against.
|
||||
* @param args - the model-generated arguments, however malformed.
|
||||
* @returns the violation messages in declaration order; empty means valid.
|
||||
*/
|
||||
export function validateArgs(spec: SchemaSpec, args: unknown): string[] {
|
||||
return checkSpec(spec, args, '')
|
||||
@@ -289,6 +295,13 @@ export interface DefineToolOptions<S extends SchemaSpec> {
|
||||
* standard JSON Schema at runtime.
|
||||
*/
|
||||
parameters: S
|
||||
/**
|
||||
* Optional cooperative tool-call timeout budget in milliseconds. When given it
|
||||
* must be a positive finite number; it is attached to the produced
|
||||
* {@link ToolDefinition} for `@deepseek-ai/dsh-timeout-policy` to enforce and
|
||||
* is never sent to the model.
|
||||
*/
|
||||
timeoutMs?: number
|
||||
/**
|
||||
* Tool execution function. `args` is typed as {@link InferArgs<S>} — zero
|
||||
* casts needed. Returns either a bare {@link ContentBlock}`[]` (model-facing
|
||||
@@ -340,6 +353,13 @@ export interface DefineToolOptions<S extends SchemaSpec> {
|
||||
* Raw JSON-Schema tool definitions (from MCP servers) are still accepted
|
||||
* by `ToolRegistry.register()` directly — `defineTool` is sugar for
|
||||
* first-party plugin authors.
|
||||
* @param options - the tool's name, description, typed parameter schema,
|
||||
* execute body, and optional presenters.
|
||||
* @returns a registry-ready {@link ToolDefinition}: its `execute` validates the
|
||||
* raw args first (throwing {@link ToolArgsError} on mismatch, which the
|
||||
* registry turns into an isError result), and its presenters validate softly
|
||||
* (returning undefined on mismatch, since replay may feed them older-schema
|
||||
* args).
|
||||
*/
|
||||
export function defineTool<S extends SchemaSpec>(options: DefineToolOptions<S>): ToolDefinition {
|
||||
// Object-literal execute methods don't use `this`; the reference is safe.
|
||||
@@ -349,10 +369,14 @@ export function defineTool<S extends SchemaSpec>(options: DefineToolOptions<S>):
|
||||
const userPresentCall = options.presentCall
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
const userPresentResult = options.presentResult
|
||||
if (options.timeoutMs !== undefined && (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0)) {
|
||||
throw new Error(`defineTool(${options.name}): timeoutMs must be a positive finite number`)
|
||||
}
|
||||
const tool: ToolDefinition = {
|
||||
name: options.name,
|
||||
description: options.description,
|
||||
parameters: schemaSpecToJsonSchema(options.parameters) as unknown as Record<string, unknown>,
|
||||
...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
|
||||
async execute(args: unknown, exec: ToolExecution): Promise<ToolExecuteReturn> {
|
||||
// Validate the model-generated args before the typed body runs. On
|
||||
// mismatch we throw ToolArgsError; the registry turns it into an
|
||||
|
||||
@@ -35,7 +35,7 @@ describe('gen-tool-catalog collectToolCatalog', () => {
|
||||
it('boots every shipped tool package and harvests its model-facing schemas', async () => {
|
||||
const catalog = await collectToolCatalog()
|
||||
const names = catalog.flatMap(entry => entry.schemas.map(s => s.name)).sort()
|
||||
expect(names).toEqual(['bash', 'bash_kill', 'bash_output', 'edit', 'read', 'skill', 'subagent', 'todo_write', 'web_fetch', 'web_search', 'write'])
|
||||
expect(names).toEqual(['ask_user_question', 'bash', 'bash_kill', 'bash_output', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'edit', 'read', 'skill', 'subagent', 'todo_write', 'web_fetch', 'web_search', 'write'])
|
||||
// Every tool carries a JSON-Schema `parameters` object (what the model sees).
|
||||
for (const entry of catalog) {
|
||||
for (const schema of entry.schemas) {
|
||||
|
||||
304
packages/core/tools/tests/json-schema.spec.ts
Normal file
304
packages/core/tools/tests/json-schema.spec.ts
Normal file
@@ -0,0 +1,304 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
assertSupportedOutputSchema,
|
||||
OutputSchemaError,
|
||||
validateStructuredValue,
|
||||
type StructuredOutputSchema,
|
||||
} from '../src/json-schema.ts'
|
||||
|
||||
/** Assert-and-narrow helper: the asserted schema, typed. */
|
||||
function asserted(schema: unknown): StructuredOutputSchema {
|
||||
assertSupportedOutputSchema(schema)
|
||||
return schema
|
||||
}
|
||||
|
||||
/** The violations OutputSchemaError carries for a bad schema (throws if it passes). */
|
||||
function violationsOf(schema: unknown): string[] {
|
||||
try {
|
||||
assertSupportedOutputSchema(schema)
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof OutputSchemaError) return error.violations
|
||||
throw error
|
||||
}
|
||||
throw new Error('expected the schema to be rejected')
|
||||
}
|
||||
|
||||
describe('assertSupportedOutputSchema', () => {
|
||||
it('accepts a representative subset schema (all supported keywords)', () => {
|
||||
const schema = asserted({
|
||||
type: 'object',
|
||||
description: 'a finding',
|
||||
title: 'Finding',
|
||||
properties: {
|
||||
file: { type: 'string', description: 'path' },
|
||||
line: { type: 'integer' },
|
||||
severity: { type: 'string', enum: ['low', 'high'] },
|
||||
kind: { type: 'string', const: 'bug' },
|
||||
score: { type: 'number' },
|
||||
confirmed: { type: 'boolean' },
|
||||
parent: { type: 'null' },
|
||||
tags: { type: 'array', items: { type: 'string' } },
|
||||
nested: {
|
||||
type: 'object',
|
||||
properties: { x: { type: 'number', default: 3, examples: [1, 2] } },
|
||||
additionalProperties: false,
|
||||
},
|
||||
anything: { type: 'array' },
|
||||
},
|
||||
required: ['file', 'line'],
|
||||
additionalProperties: true,
|
||||
})
|
||||
expect(schema.type).toBe('object')
|
||||
})
|
||||
|
||||
it('rejects a non-object root (scalar/array-rooted schemas)', () => {
|
||||
expect(violationsOf({ type: 'string' })).toEqual(['schema.type must be "object" (structured output is object-rooted)'])
|
||||
expect(violationsOf({ type: 'array', items: { type: 'string' } }))
|
||||
.toContain('schema.type must be "object" (structured output is object-rooted)')
|
||||
})
|
||||
|
||||
it('rejects non-object schema nodes and missing/unknown type', () => {
|
||||
expect(violationsOf('nope')).toEqual(['schema must be a schema object'])
|
||||
expect(violationsOf(null)).toEqual(['schema must be a schema object'])
|
||||
expect(violationsOf([])).toEqual(['schema must be a schema object'])
|
||||
expect(violationsOf({})).toEqual(['schema.type must be one of object/array/string/number/integer/boolean/null'])
|
||||
expect(violationsOf({ type: 'tuple' })[0]).toMatch(/type must be one of/)
|
||||
expect(violationsOf({ type: 'object', properties: { a: 'str' } })).toEqual(['schema.properties.a must be a schema object'])
|
||||
})
|
||||
|
||||
it('rejects type ARRAYS with a dedicated message', () => {
|
||||
expect(violationsOf({ type: ['string', 'null'] }))
|
||||
.toEqual(['schema.type must be a single type string (type arrays are not supported)'])
|
||||
})
|
||||
|
||||
it('rejects unsupported constraint keywords loudly (never accepted-then-ignored)', () => {
|
||||
for (const keyword of ['oneOf', 'anyOf', 'allOf', 'not', 'pattern', 'minimum', 'maxLength', '$ref']) {
|
||||
const bad = violationsOf({ type: 'object', [keyword]: [] })
|
||||
expect(bad.some(v => v.includes(`schema.${keyword} is not a supported keyword`))).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('reports EVERY violation, not just the first', () => {
|
||||
const bad = violationsOf({
|
||||
type: 'object',
|
||||
pattern: 'x',
|
||||
properties: { a: { type: 'weird' }, b: { type: 'string', minimum: 1 } },
|
||||
})
|
||||
expect(bad.length).toBe(3)
|
||||
})
|
||||
|
||||
it('rejects keywords on the wrong type (items on object, properties on string, enum on object)', () => {
|
||||
expect(violationsOf({ type: 'object', items: { type: 'string' } }))
|
||||
.toEqual(['schema.items is not supported on type "object"'])
|
||||
expect(violationsOf({ type: 'object', properties: { a: { type: 'string', properties: {} } } }))
|
||||
.toEqual(['schema.properties.a.properties is not supported on type "string"'])
|
||||
expect(violationsOf({ type: 'object', enum: [1] }))
|
||||
.toEqual(['schema.enum is not supported on type "object"'])
|
||||
expect(violationsOf({ type: 'object', properties: { a: { type: 'array', const: 1 } } }))
|
||||
.toEqual(['schema.properties.a.const is not supported on type "array"'])
|
||||
})
|
||||
|
||||
it('validates required: must be string[] naming declared properties', () => {
|
||||
expect(violationsOf({ type: 'object', required: 'file' }))
|
||||
.toEqual(['schema.required must be an array of strings'])
|
||||
expect(violationsOf({ type: 'object', required: [1] }))
|
||||
.toEqual(['schema.required must be an array of strings'])
|
||||
expect(violationsOf({ type: 'object', properties: { a: { type: 'string' } }, required: ['b'] }))
|
||||
.toEqual(['schema.required names "b" which is not in properties'])
|
||||
expect(violationsOf({ type: 'object', required: ['a'] }))
|
||||
.toEqual(['schema.required names "a" which is not in properties'])
|
||||
})
|
||||
|
||||
it('validates additionalProperties must be boolean and enum/const must be scalars', () => {
|
||||
expect(violationsOf({ type: 'object', additionalProperties: {} }))
|
||||
.toEqual(['schema.additionalProperties must be a boolean'])
|
||||
expect(violationsOf({ type: 'object', properties: { a: { type: 'string', enum: [] } } }))
|
||||
.toEqual(['schema.properties.a.enum must be a non-empty array of scalars'])
|
||||
expect(violationsOf({ type: 'object', properties: { a: { type: 'string', enum: [{}] } } }))
|
||||
.toEqual(['schema.properties.a.enum must be a non-empty array of scalars'])
|
||||
expect(violationsOf({ type: 'object', properties: { a: { type: 'string', enum: 'x' } } }))
|
||||
.toEqual(['schema.properties.a.enum must be a non-empty array of scalars'])
|
||||
expect(violationsOf({ type: 'object', properties: { a: { type: 'number', enum: [Number.NaN] } } }))
|
||||
.toEqual(['schema.properties.a.enum must be a non-empty array of scalars'])
|
||||
expect(violationsOf({ type: 'object', properties: { a: { type: 'string', const: {} } } }))
|
||||
.toEqual(['schema.properties.a.const must be a scalar'])
|
||||
})
|
||||
|
||||
it('rejects non-string description/title and non-JSON annotation payloads', () => {
|
||||
expect(violationsOf({ type: 'object', description: 7 }))
|
||||
.toEqual(['schema.description must be a string'])
|
||||
expect(violationsOf({ type: 'object', title: 7 }))
|
||||
.toEqual(['schema.title must be a string'])
|
||||
expect(violationsOf({ type: 'object', default: () => 1 }))
|
||||
.toEqual(['schema.default annotation must be JSON data'])
|
||||
expect(violationsOf({ type: 'object', examples: [undefined] }))
|
||||
.toEqual(['schema.examples annotation must be JSON data'])
|
||||
expect(violationsOf({ type: 'object', examples: [Number.POSITIVE_INFINITY] }))
|
||||
.toEqual(['schema.examples annotation must be JSON data'])
|
||||
// A cyclic annotation payload is caught by the JSON-data walk.
|
||||
const cyclicAnnotation: Record<string, unknown> = {}
|
||||
cyclicAnnotation.self = cyclicAnnotation
|
||||
expect(violationsOf({ type: 'object', default: cyclicAnnotation }))
|
||||
.toEqual(['schema.default annotation must be JSON data'])
|
||||
// Object/array annotations that ARE JSON data pass.
|
||||
asserted({ type: 'object', default: { a: [1, 'x', null, true] } })
|
||||
})
|
||||
|
||||
it('rejects a circular schema instead of recursing forever', () => {
|
||||
const node: Record<string, unknown> = { type: 'object' }
|
||||
node.properties = { self: node }
|
||||
expect(violationsOf(node)).toEqual(['schema.properties.self is circular'])
|
||||
})
|
||||
|
||||
it('accepts the same subschema object reused in two SIBLING positions (a DAG, not a cycle)', () => {
|
||||
const leaf = { type: 'string' }
|
||||
asserted({ type: 'object', properties: { a: leaf, b: leaf } })
|
||||
})
|
||||
|
||||
it('required cannot be satisfied by INHERITED names — `toString` is not a declared property', () => {
|
||||
// `'toString' in {}` is true via Object.prototype; the declared-property
|
||||
// contract must be an own-property check.
|
||||
expect(violationsOf({ type: 'object', properties: {}, required: ['toString'] }))
|
||||
.toEqual(['schema.required names "toString" which is not in properties'])
|
||||
})
|
||||
|
||||
it('rejects exotic host objects where the subset expects plain JSON structure', () => {
|
||||
// A Map as `properties` has no own enumerable entries: structurally it
|
||||
// would read as "no properties" and serialize to {} — lossy, not loud.
|
||||
expect(violationsOf({ type: 'object', properties: new Map() }))
|
||||
.toEqual(['schema.properties must be an object of schemas'])
|
||||
// A Date node is not a schema object even though Object.values(date) is [].
|
||||
expect(violationsOf({ type: 'object', properties: { at: new Date(0) } }))
|
||||
.toEqual(['schema.properties.at must be a schema object'])
|
||||
})
|
||||
|
||||
it('rejects exotic annotation payloads that would serialize lossily', () => {
|
||||
expect(violationsOf({ type: 'object', default: new Date(0) }))
|
||||
.toEqual(['schema.default annotation must be JSON data'])
|
||||
expect(violationsOf({ type: 'object', examples: [new Map()] }))
|
||||
.toEqual(['schema.examples annotation must be JSON data'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateStructuredValue', () => {
|
||||
const schema = asserted({
|
||||
type: 'object',
|
||||
properties: {
|
||||
file: { type: 'string' },
|
||||
line: { type: 'integer' },
|
||||
score: { type: 'number' },
|
||||
confirmed: { type: 'boolean' },
|
||||
parent: { type: 'null' },
|
||||
severity: { type: 'string', enum: ['low', 'high'] },
|
||||
kind: { type: 'string', const: 'bug' },
|
||||
tags: { type: 'array', items: { type: 'string' } },
|
||||
free: { type: 'array' },
|
||||
nested: { type: 'object', properties: { x: { type: 'number' } }, required: ['x'], additionalProperties: false },
|
||||
},
|
||||
required: ['file'],
|
||||
})
|
||||
|
||||
it('accepts a fully valid value (empty violations)', () => {
|
||||
expect(validateStructuredValue(schema, {
|
||||
file: 'a.ts', line: 3, score: 0.5, confirmed: true, parent: null,
|
||||
severity: 'high', kind: 'bug', tags: ['x'], free: [1, { any: true }], nested: { x: 1 },
|
||||
})).toEqual([])
|
||||
})
|
||||
|
||||
it('reports missing required and wrong root type', () => {
|
||||
expect(validateStructuredValue(schema, {})).toEqual(['missing required property "value.file"'])
|
||||
expect(validateStructuredValue(schema, 'nope')).toEqual(['"value" must be an object'])
|
||||
expect(validateStructuredValue(schema, [])).toEqual(['"value" must be an object'])
|
||||
})
|
||||
|
||||
it('type-checks every scalar branch with path-qualified messages', () => {
|
||||
expect(validateStructuredValue(schema, { file: 1 })).toEqual(['"value.file" must be a string'])
|
||||
expect(validateStructuredValue(schema, { file: 'a', line: 1.5 })).toEqual(['"value.line" must be an integer'])
|
||||
expect(validateStructuredValue(schema, { file: 'a', line: 'x' })).toEqual(['"value.line" must be an integer'])
|
||||
expect(validateStructuredValue(schema, { file: 'a', score: 'x' })).toEqual(['"value.score" must be a finite number'])
|
||||
expect(validateStructuredValue(schema, { file: 'a', score: Number.NaN })).toEqual(['"value.score" must be a finite number'])
|
||||
expect(validateStructuredValue(schema, { file: 'a', confirmed: 'yes' })).toEqual(['"value.confirmed" must be a boolean'])
|
||||
expect(validateStructuredValue(schema, { file: 'a', parent: 0 })).toEqual(['"value.parent" must be null'])
|
||||
})
|
||||
|
||||
it('enforces enum membership and const equality', () => {
|
||||
expect(validateStructuredValue(schema, { file: 'a', severity: 'mid' }))
|
||||
.toEqual(['"value.severity" must be one of ["low","high"]'])
|
||||
expect(validateStructuredValue(schema, { file: 'a', kind: 'feature' }))
|
||||
.toEqual(['"value.kind" must be "bug"'])
|
||||
})
|
||||
|
||||
it('checks arrays per index; an items-less array accepts anything', () => {
|
||||
expect(validateStructuredValue(schema, { file: 'a', tags: 'x' })).toEqual(['"value.tags" must be an array'])
|
||||
expect(validateStructuredValue(schema, { file: 'a', tags: ['ok', 2] })).toEqual(['"value.tags[1]" must be a string'])
|
||||
expect(validateStructuredValue(schema, { file: 'a', free: [{ deep: [1] }, null] })).toEqual([])
|
||||
})
|
||||
|
||||
it('recurses into nested objects: required + additionalProperties: false', () => {
|
||||
expect(validateStructuredValue(schema, { file: 'a', nested: {} }))
|
||||
.toEqual(['missing required property "value.nested.x"'])
|
||||
expect(validateStructuredValue(schema, { file: 'a', nested: { x: 1, y: 2 } }))
|
||||
.toEqual(['"value.nested.y" is not a declared property (additionalProperties: false)'])
|
||||
expect(validateStructuredValue(schema, { file: 'a', nested: 3 }))
|
||||
.toEqual(['"value.nested" must be an object'])
|
||||
})
|
||||
|
||||
it('a required key present-but-undefined counts as missing', () => {
|
||||
expect(validateStructuredValue(schema, { file: undefined })).toEqual(['missing required property "value.file"'])
|
||||
})
|
||||
|
||||
it('inherited properties satisfy nothing: required, additionalProperties, and recursion are own-property only', () => {
|
||||
// required: ['toString'] must NOT be satisfied by Object.prototype.toString.
|
||||
expect(validateStructuredValue(
|
||||
asserted({ type: 'object', properties: { toString: { type: 'string' } }, required: ['toString'] }),
|
||||
{},
|
||||
)).toEqual(['missing required property "value.toString"'])
|
||||
// additionalProperties: false must flag an OWN `toString` key even though
|
||||
// `'toString' in properties` is true via the prototype.
|
||||
expect(validateStructuredValue(
|
||||
asserted({ type: 'object', additionalProperties: false }),
|
||||
{ toString: 1 },
|
||||
)).toEqual(['"value.toString" is not a declared property (additionalProperties: false)'])
|
||||
// A declared property the value does NOT carry must not be validated
|
||||
// against the value's INHERITED member (constructor is a function on
|
||||
// every plain object's prototype, not a carried property).
|
||||
expect(validateStructuredValue(
|
||||
asserted({ type: 'object', properties: { constructor: { type: 'string' } } }),
|
||||
{},
|
||||
)).toEqual([])
|
||||
})
|
||||
|
||||
it('a non-plain object value is not an object in the JSON sense', () => {
|
||||
expect(validateStructuredValue(asserted({ type: 'object' }), new Date(0)))
|
||||
.toEqual(['"value" must be an object'])
|
||||
})
|
||||
|
||||
it('collects multiple violations across branches in one pass', () => {
|
||||
expect(validateStructuredValue(schema, { line: 'x', severity: 'mid' })).toEqual([
|
||||
'missing required property "value.file"',
|
||||
'"value.line" must be an integer',
|
||||
'"value.severity" must be one of ["low","high"]',
|
||||
])
|
||||
})
|
||||
|
||||
it('null-typed const/enum work through the scalar path', () => {
|
||||
const nullish = asserted({ type: 'object', properties: { a: { type: 'null', const: null } } })
|
||||
expect(validateStructuredValue(nullish, { a: null })).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects a non-object properties value in the schema walk', () => {
|
||||
expect(violationsOf({ type: 'object', properties: [] }))
|
||||
.toEqual(['schema.properties must be an object of schemas'])
|
||||
})
|
||||
|
||||
it('an object schema without properties/required only type-checks its value', () => {
|
||||
const bare = asserted({ type: 'object' })
|
||||
expect(validateStructuredValue(bare, { any: ['thing'] })).toEqual([])
|
||||
expect(validateStructuredValue(bare, 7)).toEqual(['"value" must be an object'])
|
||||
})
|
||||
|
||||
it('validateStructuredValue throws on a type the assert would never let through (assertNever backstop)', () => {
|
||||
const forged = { type: 'tuple' } as unknown as StructuredOutputSchema
|
||||
expect(() => validateStructuredValue(forged, 1)).toThrow(/tuple/)
|
||||
})
|
||||
})
|
||||
@@ -5,6 +5,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, {
|
||||
defineTool, schemaSpecToJsonSchema, validateArgs, ToolArgsError, ToolNotFoundError,
|
||||
type InferArgs, type SchemaSpec, type PreToolDecision, type PostToolDecision,
|
||||
type ToolExecution, type ToolExecutionResult,
|
||||
} from '@deepseek-ai/dsh-tools'
|
||||
|
||||
async function setup() {
|
||||
@@ -62,6 +63,17 @@ describe('ToolRegistry', () => {
|
||||
expect(schema.execute).toBeUndefined()
|
||||
})
|
||||
|
||||
it('schemas() excludes timeoutMs — the budget must never reach the model', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'budgeted', description: 'has a budget', parameters: {}, timeoutMs: 5_000,
|
||||
async execute() { return [{ type: 'text' as const, text: 'ok' }] },
|
||||
}))
|
||||
const schema = ctx.tools.schemas().find(s => s.name === 'budgeted')
|
||||
expect(schema).toBeDefined()
|
||||
expect('timeoutMs' in (schema as object)).toBe(false)
|
||||
})
|
||||
|
||||
it('executes a tool and returns its content', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(echoTool)
|
||||
@@ -272,6 +284,151 @@ describe('ToolRegistry', () => {
|
||||
expect(order).toEqual(['pre:before', 'pre:after', 'post:before', 'post:after'])
|
||||
})
|
||||
|
||||
it('runs tools/execute after an allowed pre-execute, around dispatch, and before post-execute', async () => {
|
||||
const ctx = await setup()
|
||||
const order: string[] = []
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'traced',
|
||||
description: 'echo',
|
||||
parameters: { text: { type: 'string' } },
|
||||
async execute(args) {
|
||||
order.push('dispatch')
|
||||
return [{ type: 'text' as const, text: args.text ?? '' }]
|
||||
},
|
||||
}))
|
||||
|
||||
ctx.on('tools/pre-execute', async (_exec, next) => { order.push('pre'); return next() })
|
||||
ctx.on('tools/execute', async (_exec: ToolExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> => {
|
||||
order.push('execute:before')
|
||||
const result = await next()
|
||||
order.push('execute:after')
|
||||
return result
|
||||
})
|
||||
ctx.on('tools/post-execute', async (_exec, _result, next) => { order.push('post'); return next() })
|
||||
|
||||
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'traced', arguments: { text: 'hi' } })
|
||||
expect(result).toEqual({ callId: CallId('c1'), content: [{ type: 'text', text: 'hi' }], isError: false })
|
||||
// The around seam wraps dispatch; pre gates before it, post runs over its result.
|
||||
expect(order).toEqual(['pre', 'execute:before', 'dispatch', 'execute:after', 'post'])
|
||||
})
|
||||
|
||||
it('a pre-execute deny short-circuits before tools/execute (the seam never runs)', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(echoTool)
|
||||
|
||||
let entered = false
|
||||
ctx.on('tools/pre-execute', async (_exec, _next): Promise<PreToolDecision> => ({ kind: 'deny', reason: 'nope' }))
|
||||
ctx.on('tools/execute', async (_exec: ToolExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> => {
|
||||
entered = true
|
||||
return next()
|
||||
})
|
||||
|
||||
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content[0]).toMatchObject({ text: 'Error: nope' })
|
||||
expect(entered).toBe(false) // a denied call never enters the around-dispatch seam
|
||||
})
|
||||
|
||||
it('a thrown tool is normalized to an isError result BEFORE a tools/execute listener sees next()', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register({
|
||||
...echoTool,
|
||||
name: 'boom',
|
||||
async execute() { throw new HarnessError('kaboom', 'BOOM') },
|
||||
})
|
||||
|
||||
let seen: { isError: boolean; error?: unknown } | undefined
|
||||
ctx.on('tools/execute', async (_exec: ToolExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> => {
|
||||
const result = await next()
|
||||
// The base next() IS dispatch-with-normalization: the wrapper sees the
|
||||
// normalized isError result, never a raw throw from the tool body.
|
||||
seen = { isError: result.isError, error: result.error }
|
||||
return result
|
||||
})
|
||||
|
||||
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'boom', arguments: {} })
|
||||
expect(seen).toEqual({ isError: true, error: { name: 'HarnessError', code: 'BOOM' } })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content[0]).toMatchObject({ text: 'Error: kaboom' })
|
||||
})
|
||||
|
||||
it('a thrown tool normalized inside tools/execute still reaches post-execute', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register({
|
||||
...echoTool,
|
||||
name: 'boom',
|
||||
async execute() { throw new Error('exploded') },
|
||||
})
|
||||
|
||||
let postSaw: boolean | undefined
|
||||
ctx.on('tools/execute', async (_exec: ToolExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> => next())
|
||||
ctx.on('tools/post-execute', async (_exec, result, next) => {
|
||||
postSaw = result.isError
|
||||
return next()
|
||||
})
|
||||
|
||||
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'boom', arguments: {} })
|
||||
expect(postSaw).toBe(true) // the normalized isError still flows through post-execute
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content[0]).toMatchObject({ text: 'Error: exploded' })
|
||||
})
|
||||
|
||||
it('a tools/execute listener can replace exec.signal for the dispatched tool (deadline pattern)', async () => {
|
||||
const ctx = await setup()
|
||||
let seenSignal: AbortSignal | undefined
|
||||
ctx.tools.register({
|
||||
...echoTool,
|
||||
name: 'signal-probe',
|
||||
async execute(_args, exec) {
|
||||
seenSignal = exec.signal
|
||||
return [{ type: 'text' as const, text: 'ok' }]
|
||||
},
|
||||
})
|
||||
|
||||
const upstream = new AbortController().signal
|
||||
const replacement = new AbortController().signal
|
||||
ctx.on('tools/execute', async (exec: ToolExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> => {
|
||||
expect(exec.signal).toBe(upstream)
|
||||
// Cordis next() ignores passed arguments, so a wrapper mutates exec in
|
||||
// place (the documented "mutate the shared object, then delegate" idiom).
|
||||
exec.signal = replacement
|
||||
return next()
|
||||
})
|
||||
|
||||
await ctx.tools.execute({ callId: CallId('c1'), name: 'signal-probe', arguments: {}, signal: upstream })
|
||||
expect(seenSignal).toBe(replacement) // dispatch saw the wrapper's replacement, not the upstream
|
||||
})
|
||||
|
||||
it('a tools/execute listener can short-circuit dispatch by returning a result without next()', async () => {
|
||||
const ctx = await setup()
|
||||
let dispatched = false
|
||||
ctx.tools.register({
|
||||
...echoTool,
|
||||
name: 'never-runs',
|
||||
async execute() { dispatched = true; return [] },
|
||||
})
|
||||
|
||||
ctx.on('tools/execute', async (exec: ToolExecution, _next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> =>
|
||||
({ callId: exec.callId, content: [{ type: 'text', text: 'short-circuited' }], isError: false }))
|
||||
|
||||
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'never-runs', arguments: {} })
|
||||
expect(dispatched).toBe(false) // returning without next() skips core dispatch
|
||||
expect(result.content[0]).toMatchObject({ text: 'short-circuited' })
|
||||
})
|
||||
|
||||
it('returns an isError result when a tools/execute listener throws', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(echoTool)
|
||||
ctx.on('tools/execute', async () => { throw new Error('wrapper broke') })
|
||||
|
||||
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
|
||||
expect(result).toEqual({
|
||||
callId: CallId('c1'),
|
||||
content: [{ type: 'text', text: 'Error: wrapper broke' }],
|
||||
isError: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('returns an isError result when a tools/pre-execute listener throws', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(echoTool)
|
||||
@@ -989,6 +1146,38 @@ describe('defineTool validation (the runtime-validation RFC, part 1)', () => {
|
||||
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'raw', arguments: {} })
|
||||
expect(result.isError).toBe(false)
|
||||
})
|
||||
|
||||
it('attaches a positive-finite timeoutMs to the definition', () => {
|
||||
const tool = defineTool({
|
||||
name: 'x', description: 'd', parameters: {}, timeoutMs: 30_000,
|
||||
async execute() { return [{ type: 'text' as const, text: 'ok' }] },
|
||||
})
|
||||
expect(tool.timeoutMs).toBe(30_000)
|
||||
})
|
||||
|
||||
it('omits timeoutMs when not declared', () => {
|
||||
const tool = defineTool({
|
||||
name: 'x', description: 'd', parameters: {},
|
||||
async execute() { return [{ type: 'text' as const, text: 'ok' }] },
|
||||
})
|
||||
expect(tool.timeoutMs).toBeUndefined()
|
||||
})
|
||||
|
||||
it('throws when timeoutMs is zero or negative', () => {
|
||||
const make = (ms: number) => defineTool({
|
||||
name: 'x', description: 'd', parameters: {}, timeoutMs: ms,
|
||||
async execute() { return [{ type: 'text' as const, text: 'ok' }] },
|
||||
})
|
||||
expect(() => make(0)).toThrow('timeoutMs must be a positive finite number')
|
||||
expect(() => make(-5)).toThrow('positive finite number')
|
||||
})
|
||||
|
||||
it('throws when timeoutMs is non-finite', () => {
|
||||
expect(() => defineTool({
|
||||
name: 'x', description: 'd', parameters: {}, timeoutMs: Infinity,
|
||||
async execute() { return [{ type: 'text' as const, text: 'ok' }] },
|
||||
})).toThrow('positive finite number')
|
||||
})
|
||||
})
|
||||
|
||||
describe('defineTool presentation (presentCall / presentResult)', () => {
|
||||
|
||||
Reference in New Issue
Block a user