Merge remote-tracking branch 'origin/master' into codex/docs-graph-brainstorm
# Conflicts: # docs/module-graph.md # package.json # packages/core/tools/tests/gen-tool-catalog.spec.ts
This commit is contained in:
@@ -166,7 +166,7 @@ export interface LoopHandle {
|
||||
* → dispatch → tools/post-execute
|
||||
* session('tool/result')
|
||||
* append buffered post-execute additionalContext → session('context/message')(s)
|
||||
* drain steering → session('steering/message'); emit agent/steering
|
||||
* drain steering → session('steering/message')
|
||||
* session('step/end') ⟵ durable step boundary (no agent/* mirror)
|
||||
* cont = waterfall agent/turn-continuation ⟵ ContinuationDecision; default
|
||||
* {action: hadToolCalls||steered ? 'continue':'stop'}; a continue.reason is
|
||||
@@ -420,7 +420,7 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle,
|
||||
|
||||
// Steering from the previous round's continuation listeners joins before
|
||||
// the request.
|
||||
drainSteering(ctx, agent, turn)
|
||||
drainSteering(agent, turn)
|
||||
|
||||
// The step's AbortController exists BEFORE any async pre-step work so a
|
||||
// dispose() or cancel() — in a synchronous turn-start listener or an
|
||||
@@ -529,7 +529,7 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle,
|
||||
if (stepReason) reason = stepReason
|
||||
|
||||
// Steering that arrived during streaming/tool execution.
|
||||
const steered = drainSteering(ctx, agent, turn)
|
||||
const steered = drainSteering(agent, turn)
|
||||
|
||||
if (closeStep()) break
|
||||
|
||||
@@ -635,11 +635,10 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle,
|
||||
}
|
||||
|
||||
/** Drain the steering queue into the session. Returns whether any arrived. */
|
||||
function drainSteering(ctx: Context, agent: ReactLoopAgent, turn: number): boolean {
|
||||
function drainSteering(agent: ReactLoopAgent, turn: number): boolean {
|
||||
const messages = agent.inbox.drainSteering()
|
||||
for (const message of messages) {
|
||||
agent.session.append('steering/message', { turn, content: message.content, source: message.source }, { surfaceOp: 'append' })
|
||||
ctx.emit('agent/steering', agent, turn, message.content, message.source)
|
||||
}
|
||||
return messages.length > 0
|
||||
}
|
||||
|
||||
@@ -410,7 +410,7 @@ describe('MEDIUM: misc registry and config fixes', () => {
|
||||
expect(agent.session.deriveMessages().at(-1)?.content).toEqual([{ type: 'text', text: 'routed' }])
|
||||
})
|
||||
|
||||
it('agent/queued carries the resolved source; agent/steering carries its source', async () => {
|
||||
it('agent/queued carries the resolved source; steering/message records its source', async () => {
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'noop', {}), textResponse('done')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
@@ -425,15 +425,16 @@ describe('MEDIUM: misc registry and config fixes', () => {
|
||||
}))
|
||||
|
||||
const queuedSources: { source: MessageSource; steering: boolean }[] = []
|
||||
const steeringSources: MessageSource[] = []
|
||||
ctx.on('agent/queued', (_agent, _content, info) => void queuedSources.push(info))
|
||||
ctx.on('agent/steering', (_agent, _turn, _content, source) => void steeringSources.push(source))
|
||||
|
||||
send(agent, 'go') // no explicit source → default {kind:'user'} must be visible
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(queuedSources[0]).toEqual({ source: { kind: 'user' }, steering: false })
|
||||
expect(queuedSources[1]).toEqual({ source: { kind: 'plugin', plugin: 'goal' }, steering: true })
|
||||
// The drain appends the durable steering/message with the caller's source
|
||||
// intact — the log, not a transient emit, is where consumers read it.
|
||||
const steeringSources = agent.session.events.flatMap(e => e.type === 'steering/message' ? [e.data.source] : [])
|
||||
expect(steeringSources).toEqual([{ kind: 'plugin', plugin: 'goal' }])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -50,9 +50,8 @@ Turn and step boundaries are NOT mirrored as `agent/*` emits: a consumer that ne
|
||||
|
||||
Tool interception is the `tools/pre-execute` / `tools/post-execute` pair in [`dsh-tools`](../tools/README.md) (`PreToolDecision` allow/deny/ask, `PostToolDecision` accept/block) — same typed-Decision idiom, owned there because it is the tool registry's seam.
|
||||
|
||||
#### Live control notifications (emit)
|
||||
#### Error notifications (emit)
|
||||
|
||||
- `agent/steering` — steering content injected mid-turn
|
||||
- `agent/error` — step/turn error
|
||||
|
||||
The model's token stream is NOT an `agent/*` event: read it off the durable `session/event` feed as `assistant/chunk` (the same feed persistence and the ACP bridge use).
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
* `agent/request`/`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/steering`, `agent/session-start`)
|
||||
* `agent/disposed`, `agent/queued`, `agent/session-start`)
|
||||
* that notify with the `Agent` in hand. Turn/step boundaries are NOT here —
|
||||
* they are durable `session/event` records. Answers "right now, with the agent
|
||||
* object — intercept or observe."
|
||||
@@ -367,16 +367,7 @@ declare module 'cordis' {
|
||||
*/
|
||||
'agent/turn-continuation'(agent: Agent, turn: number, defaultDecision: ContinuationDecision, next: () => Promise<ContinuationDecision>): Promise<ContinuationDecision>
|
||||
|
||||
// ---- streaming + tool notifications (emit) ----
|
||||
/**
|
||||
* Steering content was injected into a running turn.
|
||||
* @param agent - the agent that absorbed the steering.
|
||||
* @param turn - the running turn that received it.
|
||||
* @param content - the injected blocks.
|
||||
* @param source - the steering message's resolved source.
|
||||
* @mode emit
|
||||
*/
|
||||
'agent/steering'(agent: Agent, turn: number, content: ContentBlock[], source: MessageSource): void
|
||||
// ---- error notifications (emit) ----
|
||||
/**
|
||||
* A step or turn errored. The loop reports a failure here (plus the logger)
|
||||
* even when the error has no in-turn position for a session `error` event.
|
||||
|
||||
@@ -49,9 +49,9 @@ Plain class (not a Cordis Service). Create via `ctx.sessions.create()`.
|
||||
|
||||
### Session event vocabulary (`types.ts`)
|
||||
|
||||
The append-only log: `turn/start`, `turn/end`, `step/start`, `step/end`, `user/message`, `prompt/blocked`, `assistant/message`, `assistant/chunk`, `tool/call`, `tool/result`, `steering/message`, `context/message`, `todo/write`. 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/log-events.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` — the compaction seam adds `compact/start`, `compact/summary`, and `compact/end`.
|
||||
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.
|
||||
|
||||
Also defines `TurnTriggerMap` and `TurnEndReasonMap` (merge-extensible sum types for typed turn boundaries — `kind`-tagged instead of strings).
|
||||
|
||||
|
||||
@@ -91,7 +91,6 @@ export interface CreateSessionOptions {
|
||||
*/
|
||||
export interface TurnTriggerMap {
|
||||
message: { kind: 'message'; source: MessageSource }
|
||||
continuation: { kind: 'continuation' }
|
||||
/**
|
||||
* An out-of-band context injection (`agent.inject()`) made while the agent
|
||||
* was idle. The loop wraps the injected `context/message` in a one-shot turn
|
||||
@@ -198,9 +197,22 @@ export interface TodoItem {
|
||||
* the invariants plugin checks, is a breaking change to the on-disk format.
|
||||
*/
|
||||
export interface SessionEventMap {
|
||||
/**
|
||||
* Opens turn `turn`. `trigger` records what started it — a drained message
|
||||
* batch or an idle-time injection. The turn is the durability/replay
|
||||
* boundary: every event sits between a `turn/start` and its matching
|
||||
* `turn/end` (the turn-enclosure invariant).
|
||||
*/
|
||||
'turn/start': { turn: number; trigger: TurnTrigger }
|
||||
/**
|
||||
* Closes turn `turn` with the {@link TurnEndReason} that ended it. The loop
|
||||
* fires the awaited `session/flush` checkpoint at every turn end, so the turn
|
||||
* boundary is also the durable-commit boundary.
|
||||
*/
|
||||
'turn/end': { turn: number; reason: TurnEndReason }
|
||||
/** Opens step `step` of turn `turn` — one model call plus the tool executions it requested. */
|
||||
'step/start': { turn: number; step: number }
|
||||
/** Closes step `step` of turn `turn`. */
|
||||
'step/end': { turn: number; step: number }
|
||||
/** A user-visible prompt (queued message drained at turn start). */
|
||||
'user/message': { content: ContentBlock[]; source: MessageSource }
|
||||
@@ -230,6 +242,11 @@ export interface SessionEventMap {
|
||||
* usage record). `usage` is absent when the adapter reported none.
|
||||
*/
|
||||
'assistant/message': { turn: number; step: number; content: ContentBlock[]; usage?: TokenUsage }
|
||||
/**
|
||||
* The model requested one tool invocation: `name` with the raw `arguments`
|
||||
* JSON string exactly as the model produced it (unparsed). `callId` pairs the
|
||||
* call with its `tool/result`.
|
||||
*/
|
||||
'tool/call': { turn: number; step: number; callId: CallId; name: string; arguments: string }
|
||||
/**
|
||||
* A completed tool call's model-facing result, plus an optional tool-private
|
||||
|
||||
225
packages/core/session/tests/gen-persistence-catalog.spec.ts
Normal file
225
packages/core/session/tests/gen-persistence-catalog.spec.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* Negative-path tests for the persistence log catalog generator
|
||||
* (`scripts/gen-persistence-catalog.ts`).
|
||||
*
|
||||
* The generated catalog is frozen by a regenerate-and-diff freshness gate, so
|
||||
* the freshness half is exercised by `pnpm run verify-persistence-catalog` in
|
||||
* CI. What a freshness diff CANNOT prove is that the generator REJECTS
|
||||
* malformed source the way it promises to — a member without description
|
||||
* prose, a forbidden `@mode` tag, a non-literal member name, a duplicate event
|
||||
* declaration, a missing or ambiguous `SurfaceEventType` union, a stale union
|
||||
* member. These tests drive the exported collectors against synthetic fixture
|
||||
* packages to prove each guard fires (and that well-formed declarations pass),
|
||||
* mirroring the gen-cordis-catalog negative tests.
|
||||
*/
|
||||
|
||||
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 {
|
||||
annotateSurface,
|
||||
collectLogEvents,
|
||||
collectSurfaceEventTypes,
|
||||
render,
|
||||
} from '../../../../scripts/gen-persistence-catalog.ts'
|
||||
|
||||
/** Create a fixture scan root; `files` maps `packages/…`-relative paths to source. */
|
||||
function fixtureRoot(files: Record<string, string>): string {
|
||||
const root = mkdtempSync(join(tmpdir(), 'persistence-catalog-'))
|
||||
for (const [rel, source] of Object.entries(files)) {
|
||||
const abs = join(root, rel)
|
||||
mkdirSync(join(abs, '..'), { recursive: true })
|
||||
writeFileSync(abs, source)
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
const roots: string[] = []
|
||||
const make = (files: Record<string, string>): string => {
|
||||
const r = fixtureRoot(files)
|
||||
roots.push(r)
|
||||
return r
|
||||
}
|
||||
|
||||
/** A merge-form declaration file wrapping `members` in the session module. */
|
||||
const merge = (members: string): string =>
|
||||
`declare module '@deepseek-ai/dsh-session' {\n interface SessionEventMap {\n${members}\n }\n}\n`
|
||||
|
||||
afterEach(() => {
|
||||
while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
/** The manifest that marks a fixture package as the owning session package. */
|
||||
const OWNER_MANIFEST = '{ "name": "@deepseek-ai/dsh-session" }\n'
|
||||
|
||||
describe('gen-persistence-catalog collectLogEvents', () => {
|
||||
it('extracts a documented member of the owning top-level interface', () => {
|
||||
const events = collectLogEvents(make({
|
||||
'packages/core/fix/package.json': OWNER_MANIFEST,
|
||||
'packages/core/fix/src/types.ts':
|
||||
'export interface SessionEventMap {\n /** A thing was recorded. */\n \'fix/happened\': { turn: number }\n}\n',
|
||||
}))
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events[0]).toMatchObject({
|
||||
name: 'fix/happened',
|
||||
scope: 'fix',
|
||||
doc: 'A thing was recorded.',
|
||||
payload: '{ turn: number }',
|
||||
source: 'packages/core/fix/src/types.ts:3',
|
||||
})
|
||||
})
|
||||
|
||||
it('hard-errors on a top-level interface outside the owning package', () => {
|
||||
expect(() => collectLogEvents(make({
|
||||
'packages/group/alien/package.json': '{ "name": "@deepseek-ai/dsh-alien" }\n',
|
||||
'packages/group/alien/src/types.ts':
|
||||
'export interface SessionEventMap {\n /** Not the real vocabulary. */\n \'alien/event\': { turn: number }\n}\n',
|
||||
}))).toThrow(/top-level interface SessionEventMap .* is outside @deepseek-ai\/dsh-session \(package @deepseek-ai\/dsh-alien\)/)
|
||||
})
|
||||
|
||||
it('hard-errors on a non-exported top-level interface even in the owning package', () => {
|
||||
expect(() => collectLogEvents(make({
|
||||
'packages/core/fix/package.json': OWNER_MANIFEST,
|
||||
'packages/core/fix/src/helper.ts':
|
||||
'interface SessionEventMap {\n /** A local helper, not the vocabulary. */\n \'fix/local\': { turn: number }\n}\nexport const use: SessionEventMap | null = null\n',
|
||||
}))).toThrow(/is not exported; the owning vocabulary is the single exported declaration/)
|
||||
})
|
||||
|
||||
it('hard-errors when the owning interface is exported from two files', () => {
|
||||
expect(() => collectLogEvents(make({
|
||||
'packages/core/fix/package.json': OWNER_MANIFEST,
|
||||
'packages/core/fix/src/a.ts': 'export interface SessionEventMap {\n /** First home. */\n \'fix/a\': { turn: number }\n}\n',
|
||||
'packages/core/fix/src/b.ts': 'export interface SessionEventMap {\n /** Second home. */\n \'fix/b\': { turn: number }\n}\n',
|
||||
}))).toThrow(/is already declared at packages\/core\/fix\/src\/a\.ts:1; the owning vocabulary has exactly one home/)
|
||||
})
|
||||
|
||||
it('hard-errors on an extends clause (inherited keys would escape the catalog)', () => {
|
||||
expect(() => collectLogEvents(make({
|
||||
'packages/group/fix/src/types.ts':
|
||||
'interface Extra { \'fix/hidden\': { turn: number } }\ndeclare module \'@deepseek-ai/dsh-session\' {\n interface SessionEventMap extends Extra {\n /** Declared directly. */\n \'fix/direct\': { turn: number }\n }\n}\n',
|
||||
}))).toThrow(/uses extends; inherited keys would join keyof SessionEventMap without a catalog row/)
|
||||
})
|
||||
|
||||
it('extracts a member declaration-merged via the session module', () => {
|
||||
const events = collectLogEvents(make({
|
||||
'packages/group/fix/src/types.ts': merge(' /** Merged provenance. */\n \'fix/merged\': { id: string }'),
|
||||
}))
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events[0]).toMatchObject({ name: 'fix/merged', doc: 'Merged provenance.' })
|
||||
})
|
||||
|
||||
it('collapses a newline-separated multi-line payload to a valid one-line fragment', () => {
|
||||
const events = collectLogEvents(make({
|
||||
'packages/group/fix/src/types.ts': merge(
|
||||
' /** Wide payload. */\n \'fix/wide\': {\n alpha: string[]\n range: { start: number; end: number }\n count: number\n }',
|
||||
),
|
||||
}))
|
||||
expect(events[0]?.payload).toBe('{ alpha: string[]; range: { start: number; end: number }; count: number }')
|
||||
})
|
||||
|
||||
it('hard-errors on a member with no description prose', () => {
|
||||
expect(() => collectLogEvents(make({
|
||||
'packages/group/fix/src/types.ts': merge(' \'fix/undocumented\': { turn: number }'),
|
||||
}))).toThrow(/no description prose/)
|
||||
})
|
||||
|
||||
it('hard-errors on an @mode tag (a log event has no dispatch mode)', () => {
|
||||
expect(() => collectLogEvents(make({
|
||||
'packages/group/fix/src/types.ts': merge(' /**\n * Documented, but mistagged.\n * @mode emit\n */\n \'fix/tagged\': { turn: number }'),
|
||||
}))).toThrow(/carries an @mode tag/)
|
||||
})
|
||||
|
||||
it('hard-errors on an extra-indented @mode tag (does not leak into prose)', () => {
|
||||
expect(() => collectLogEvents(make({
|
||||
'packages/group/fix/src/types.ts': merge(' /**\n * Documented, but mistagged.\n * @mode emit\n */\n \'fix/indented\': { turn: number }'),
|
||||
}))).toThrow(/carries an @mode tag/)
|
||||
})
|
||||
|
||||
it('hard-errors on a method-form member (it still joins keyof SessionEventMap)', () => {
|
||||
expect(() => collectLogEvents(make({
|
||||
'packages/group/fix/src/types.ts': merge(' /** Documented, wrong shape. */\n \'fix/method\'(turn: number): void'),
|
||||
}))).toThrow(/not a property signature with an explicit payload type/)
|
||||
})
|
||||
|
||||
it('hard-errors on a property member with no payload type annotation', () => {
|
||||
expect(() => collectLogEvents(make({
|
||||
'packages/group/fix/src/types.ts': merge(' /** Documented, no payload. */\n \'fix/bare\''),
|
||||
}))).toThrow(/not a property signature with an explicit payload type/)
|
||||
})
|
||||
|
||||
it('hard-errors on a non-literal member name', () => {
|
||||
expect(() => collectLogEvents(make({
|
||||
'packages/group/fix/src/types.ts': merge(' /** Not a literal. */\n unquoted: { turn: number }'),
|
||||
}))).toThrow(/non-literal name/)
|
||||
})
|
||||
|
||||
it('hard-errors when the same event is declared twice', () => {
|
||||
expect(() => collectLogEvents(make({
|
||||
'packages/group/fix/src/a.ts': merge(' /** First. */\n \'fix/dup\': { turn: number }'),
|
||||
'packages/group/fix/src/b.ts': merge(' /** Second. */\n \'fix/dup\': { turn: number }'),
|
||||
}))).toThrow(/already declared at packages\/group\/fix\/src\/a\.ts/)
|
||||
})
|
||||
|
||||
it('aggregates every violation into one error instead of failing fast', () => {
|
||||
expect(() => collectLogEvents(make({
|
||||
'packages/group/fix/src/types.ts': merge(' \'fix/one\': { turn: number }\n \'fix/two\': { turn: number }'),
|
||||
}))).toThrow(/2 JSDoc completeness violation\(s\)[\s\S]*fix\/one[\s\S]*fix\/two/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('gen-persistence-catalog collectSurfaceEventTypes', () => {
|
||||
it('parses the literal union', () => {
|
||||
const types = collectSurfaceEventTypes(make({
|
||||
'packages/core/fix/src/types.ts': 'export type SurfaceEventType = \'fix/a\' | \'fix/b\'\n',
|
||||
}))
|
||||
expect(types).toEqual(['fix/a', 'fix/b'])
|
||||
})
|
||||
|
||||
it('hard-errors when no union is declared', () => {
|
||||
expect(() => collectSurfaceEventTypes(make({
|
||||
'packages/core/fix/src/types.ts': 'export const unrelated = 1\n',
|
||||
}))).toThrow(/no SurfaceEventType union found/)
|
||||
})
|
||||
|
||||
it('hard-errors when the union is declared more than once', () => {
|
||||
expect(() => collectSurfaceEventTypes(make({
|
||||
'packages/core/fix/src/a.ts': 'export type SurfaceEventType = \'fix/a\'\n',
|
||||
'packages/core/fix/src/b.ts': 'export type SurfaceEventType = \'fix/b\'\n',
|
||||
}))).toThrow(/declared more than once/)
|
||||
})
|
||||
|
||||
it('hard-errors on a non-string-literal union member', () => {
|
||||
expect(() => collectSurfaceEventTypes(make({
|
||||
'packages/core/fix/src/types.ts': 'export type SurfaceEventType = \'fix/a\' | number\n',
|
||||
}))).toThrow(/non-string-literal member/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('gen-persistence-catalog annotateSurface + render', () => {
|
||||
const entry = (name: string) => ({
|
||||
name,
|
||||
scope: name.split('/')[0] ?? name,
|
||||
payload: '{ turn: number }',
|
||||
doc: `Records ${name}.`,
|
||||
source: 'packages/core/fix/src/types.ts:3',
|
||||
})
|
||||
|
||||
it('badges union members surface and everything else log-only', () => {
|
||||
const annotated = annotateSurface([entry('fix/message'), entry('fix/marker')], ['fix/message'])
|
||||
expect(annotated.map(e => [e.name, e.surface])).toEqual([['fix/message', true], ['fix/marker', false]])
|
||||
})
|
||||
|
||||
it('hard-errors on a union member naming no declared event', () => {
|
||||
expect(() => annotateSurface([entry('fix/marker')], ['fix/ghost']))
|
||||
.toThrow(/'fix\/ghost' name no declared log event/)
|
||||
})
|
||||
|
||||
it('renders badges, payload fences, and the generated-file header', () => {
|
||||
const out = render(annotateSurface([entry('fix/message'), entry('fix/marker')], ['fix/message']))
|
||||
expect(out).toContain('Generated by scripts/gen-persistence-catalog.ts')
|
||||
expect(out).toContain('#### `fix/message` — surface')
|
||||
expect(out).toContain('#### `fix/marker` — log-only')
|
||||
expect(out).toContain('```ts persistence-catalog\n\'fix/marker\': { turn: number }\n```')
|
||||
})
|
||||
})
|
||||
@@ -317,21 +317,20 @@ export class ToolRegistry extends Service {
|
||||
|
||||
/**
|
||||
* Return all registered tool schemas — exactly the model-facing fields
|
||||
* (`name`, `description`, `parameters`, and `strict` when set), as sent to the
|
||||
* model via the system-prompt assembly. Constructed EXPLICITLY rather than by
|
||||
* stripping known non-schema members: a `ToolDefinition` also carries
|
||||
* `execute` and the optional `presentCall`/`presentResult` UI callbacks, and
|
||||
* those (especially the functions) must never leak into a model request. An
|
||||
* allowlist can't drift when a new non-schema member is added to the
|
||||
* definition; a denylist (rest-destructure) would silently leak it.
|
||||
* (`name`, `description`, `parameters`), as sent to the model via the
|
||||
* system-prompt assembly. Constructed EXPLICITLY rather than by stripping
|
||||
* known non-schema members: a `ToolDefinition` also carries `execute` and the
|
||||
* optional `presentCall`/`presentResult` UI callbacks, and those (especially
|
||||
* the functions) must never leak into a model request. An allowlist can't
|
||||
* drift when a new non-schema member is added to the definition; a denylist
|
||||
* (rest-destructure) would silently leak it.
|
||||
* @returns one deep-cloned schema per registered tool, in registration order.
|
||||
*/
|
||||
schemas(): ToolSchema[] {
|
||||
return [...this.store.values()].map(({ name, description, parameters, strict }): ToolSchema => ({
|
||||
return [...this.store.values()].map(({ name, description, parameters }): ToolSchema => ({
|
||||
name,
|
||||
description,
|
||||
parameters: structuredClone(parameters),
|
||||
...strict !== undefined ? { strict } : {},
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -312,8 +312,6 @@ export interface DefineToolOptions<S extends SchemaSpec> {
|
||||
* free for the same replay reason. See {@link ToolResultView}.
|
||||
*/
|
||||
presentResult?(args: InferArgs<S>, result: ToolResult): ToolResultView | undefined
|
||||
/** Whether the tool requires structured output (default false). */
|
||||
strict?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -355,7 +353,6 @@ export function defineTool<S extends SchemaSpec>(options: DefineToolOptions<S>):
|
||||
name: options.name,
|
||||
description: options.description,
|
||||
parameters: schemaSpecToJsonSchema(options.parameters) as unknown as Record<string, unknown>,
|
||||
...options.strict !== undefined ? { strict: options.strict } : {},
|
||||
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
|
||||
|
||||
@@ -106,17 +106,4 @@ describe('gen-tool-catalog render', () => {
|
||||
expect(md).toContain('```json')
|
||||
expect(md).toContain('Source: [`packages/demo/tool-demo/src/index.ts`]')
|
||||
})
|
||||
|
||||
it('renders the strict flag when a schema sets it', () => {
|
||||
const catalog: ToolCatalog = [
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-demo',
|
||||
source: 'packages/demo/tool-demo/src/index.ts',
|
||||
requires: ['ctx.tools'],
|
||||
writes: ['tool/result'],
|
||||
schemas: [{ name: 'demo', description: '', parameters: { type: 'object', properties: {} }, strict: true }],
|
||||
},
|
||||
]
|
||||
expect(render(catalog)).toContain('Strict: `true`')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -62,18 +62,6 @@ describe('ToolRegistry', () => {
|
||||
expect(schema.execute).toBeUndefined()
|
||||
})
|
||||
|
||||
it('schemas() preserves `strict` when set (allowlist keeps the model-facing fields)', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'strict-tool',
|
||||
description: 'd',
|
||||
parameters: { x: { type: 'string', required: true } },
|
||||
strict: true,
|
||||
async execute() { return [] },
|
||||
}))
|
||||
expect(ctx.tools.schemas()[0]).toMatchObject({ name: 'strict-tool', strict: true })
|
||||
})
|
||||
|
||||
it('executes a tool and returns its content', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(echoTool)
|
||||
@@ -611,44 +599,6 @@ describe('schema DSL edge cases', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('defineTool passes through strict flag when set to true', () => {
|
||||
const tool = defineTool({
|
||||
name: 'strict-tool',
|
||||
description: 'A strict tool',
|
||||
parameters: { input: { type: 'string' } },
|
||||
strict: true,
|
||||
async execute(args) {
|
||||
return [{ type: 'text' as const, text: args.input ?? '' }]
|
||||
},
|
||||
})
|
||||
expect(tool.strict).toBe(true)
|
||||
})
|
||||
|
||||
it('defineTool omits strict when not provided', () => {
|
||||
const tool = defineTool({
|
||||
name: 'non-strict-tool',
|
||||
description: 'A non-strict tool',
|
||||
parameters: { input: { type: 'string' } },
|
||||
async execute(args) {
|
||||
return [{ type: 'text' as const, text: args.input ?? '' }]
|
||||
},
|
||||
})
|
||||
expect('strict' in tool).toBe(false)
|
||||
})
|
||||
|
||||
it('defineTool strict=false is included', () => {
|
||||
const tool = defineTool({
|
||||
name: 'explicitly-non-strict',
|
||||
description: 'Explicitly non-strict',
|
||||
parameters: { input: { type: 'string' } },
|
||||
strict: false,
|
||||
async execute(args) {
|
||||
return [{ type: 'text' as const, text: args.input ?? '' }]
|
||||
},
|
||||
})
|
||||
expect(tool.strict).toBe(false)
|
||||
})
|
||||
|
||||
it('handles enum and default together in one property', () => {
|
||||
const spec = {
|
||||
level: { type: 'string', enum: ['low', 'high'], default: 'low' },
|
||||
|
||||
Reference in New Issue
Block a user