Merge origin/master: scope-aware fusion of the tools/execute seam, session-prefix, and tool-cordis
Master brought 50 commits (the tool-cordis group, dsh-code-runtime + worker, the tools/execute around-dispatch seam + timeout-policy, repeat-tool-guard, agent/session-prefix, the ui reorganization). Beyond the ten textual conflicts, the merge reconciles master's new seams with this branch's scoped-registration world: - tools/execute (new waterfall around core dispatch): dispatched with the SAME exec.agent carrier as the pre/post waterfalls — an agent.ctx wrapper times/retries only its own agent's calls — and its base thunk resolves the tool through the caller's visible view (get(exec.name, exec.agent)), so a scoped/shadowed tool dispatches and a restricted-away global stays UNKNOWN_TOOL. Declared this: Scoped<ToolRegistry> with the scope-filtered doc sentence; invariants table + verify-scoped-dispatch pin it (21 events). - agent/session-prefix (new waterfall, once per loop instance): composed via the fused agentEvents dispatcher (scope-filtered like every agent-subject event), declared this: Scoped<Agent>, table-pinned. agent/pre-step keeps master's new sessionPrefix parameter with this branch's Scoped this. - timeout-policy reads the budget through the caller's visible view (get(exec.name, exec.agent)): a scoped tool's own timeoutMs governs its calls; a global name-twin's budget is never misapplied to a shadowing per-agent variant. - tool-cordis: cordis_inspect's tools section lists the CALLING agent's view (its description promises "what you can call"); the sandbox tool façade's reads resolve through the mount's own scope, mirroring where its register lands writes; sandboxRegisterTool's return type carries the exact-disposer union honestly. dsh-scope declared as peer+dev with the project reference. - doc-sync chain unions master's verify-cordis-api with this branch's verify-scoped-dispatch; the generated catalogs, event matrix (the zero-dispatcher guard passes over master's new events), module graph, and the cordis api-catalog are regenerated on the merged surface. Full gate sequence green on the merged tree: typecheck, lint, per-file 100% coverage (2668 tests), snapshots (38), doc-sync, module graph, build, hygiene, demo smoke.
This commit is contained in:
@@ -57,12 +57,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')
|
||||
@@ -86,7 +89,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.
|
||||
|
||||
@@ -159,13 +159,17 @@ export interface LoopHandle {
|
||||
* assembly = ctx.systemPrompt.assemble(assembleContextFor(agent)) ⟵ waterfall system-prompt/assemble
|
||||
* (scope-filtered; scoped sections/tools join); renderPrompt
|
||||
* (persona section + {{variables}}) IS the full prompt
|
||||
* await events.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 (scope-filtered, fused dispatch)
|
||||
* await events.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
|
||||
@@ -478,6 +482,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 events.waterfall(
|
||||
'agent/session-prefix', 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
|
||||
@@ -488,8 +536,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 events.serial('agent/pre-step', 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 events.serial('agent/pre-step', 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()) {
|
||||
@@ -682,11 +732,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,
|
||||
events: AgentEventDispatch,
|
||||
@@ -727,22 +778,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 } : {},
|
||||
|
||||
@@ -12,11 +12,20 @@
|
||||
|
||||
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[]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -61,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')])
|
||||
|
||||
@@ -45,8 +45,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`)
|
||||
@@ -374,7 +375,12 @@ 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).
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered
|
||||
* through `agent.ctx` fires only for that agent's dispatches; a listener on a
|
||||
@@ -385,15 +391,17 @@ declare module 'cordis' {
|
||||
* @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'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, fullSystemPrompt: string, signal: AbortSignal): Promise<void> | void
|
||||
'agent/pre-step'(this: Scoped<Agent>, 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
|
||||
@@ -419,8 +427,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
|
||||
@@ -440,6 +449,58 @@ declare module 'cordis' {
|
||||
* @mode waterfall
|
||||
*/
|
||||
'agent/request'(this: Scoped<Agent>, 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.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered
|
||||
* through `agent.ctx` fires only for that agent's dispatches; a listener on a
|
||||
* plain plugin context fires for every agent. The dispatch `this` is the
|
||||
* scope carrier (`Scoped<Agent>`), built by the emitting side via
|
||||
* `scopeTarget`/`agentEvents`.
|
||||
* @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'(this: Scoped<Agent>, 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, …).
|
||||
|
||||
@@ -52,7 +52,7 @@ 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`)
|
||||
|
||||
|
||||
@@ -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 } : {},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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'>
|
||||
@@ -183,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). */
|
||||
@@ -199,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[]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -356,15 +365,21 @@ 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. */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -12,7 +12,7 @@ Tool registry and execution pipeline. Tool plugins register their schemas and ex
|
||||
- `ctx.tools.visible(scope?: ScopeKey): ToolDefinition[]` THE visibility function — restricted global layer ∪ the scope's own layer — feeding prompt assembly, `get`, and `execute`, so what the model sees and what dispatches can never disagree.
|
||||
- `ctx.tools.knownNames(scope?: ScopeKey): string[]` The PRE-restriction name universe configuration (`toolOrder`, `restrict`) validates against: a typo fails loud while a restricted-away tool stays a normal absence.
|
||||
- `ctx.tools.schemas(scope?: ScopeKey): ToolSchema[]` Schemas of everything the scope can see (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` → dispatch → `tools/post-execute` pipeline.
|
||||
- `ctx.tools.execute(exec: ToolExecution): Promise<ToolExecutionResult>` Execute one tool call through the `tools/pre-execute` → `tools/execute` → `tools/post-execute` pipeline.
|
||||
|
||||
### Injected services
|
||||
|
||||
@@ -23,12 +23,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`. Scope-filtered by `exec.agent`: an `agent.ctx` listener gates only its own agent |
|
||||
| `tools/execute` | waterfall | Around-dispatch wrapper (timeout, retry, metrics): `(exec, next)` → the dispatched `ToolExecutionResult`; `next()` is dispatch-with-normalization (resolving through the caller's visible view). Scope-filtered by `exec.agent` like the gate |
|
||||
| `tools/post-execute` | waterfall | Inspect/replace the result AFTER a tool runs, attach context; returns `PostToolDecision` |
|
||||
| `tools/change` | emit | A tool or restriction was registered or unregistered (possibly for one scope); deliberately unfiltered |
|
||||
|
||||
### 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.
|
||||
@@ -38,7 +39,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
|
||||
@@ -74,6 +75,8 @@ 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.
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
@@ -80,17 +81,41 @@ declare module 'cordis' {
|
||||
* @mode waterfall
|
||||
*/
|
||||
'tools/pre-execute'(this: Scoped<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.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed by
|
||||
* `exec.agent` — a listener registered through `agent.ctx` wraps only that
|
||||
* agent's calls; a plain plugin listener wraps every call (including
|
||||
* agent-less ones, which dispatch subject-less).
|
||||
* @param exec - the allowed call about to dispatch (name, parsed arguments, caller agent, signal).
|
||||
* @mode waterfall
|
||||
*/
|
||||
'tools/execute'(this: Scoped<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).
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed by
|
||||
* `exec.agent` — a listener registered through `agent.ctx` fires only for
|
||||
* that agent's calls; a plain plugin listener fires for every call
|
||||
@@ -132,6 +157,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
|
||||
@@ -302,7 +335,7 @@ export interface ToolRestriction {
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
@@ -533,18 +566,21 @@ 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 (or not visible to the calling agent — a
|
||||
* restricted-away global is exactly as absent as a nonexistent one), 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> {
|
||||
@@ -573,27 +609,36 @@ 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 {
|
||||
// Resolve through the CALLER's visible view ({@link get}): a scoped
|
||||
// tool shadows its global name-twin for that agent, and a
|
||||
// restricted-away global tool is exactly as absent as a nonexistent
|
||||
// one — same UNKNOWN_TOOL result, no capability leak in the error.
|
||||
const tool = this.get(exec.name, exec.agent)
|
||||
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. Dispatched with the
|
||||
// same carrier as the gate, so an `agent.ctx` wrapper wraps only its own
|
||||
// agent's calls. ---
|
||||
const result = await this.ctx.waterfall(
|
||||
carrier, 'tools/execute', exec,
|
||||
async (): Promise<ToolExecutionResult> => {
|
||||
try {
|
||||
// Resolve through the CALLER's visible view ({@link get}): a scoped
|
||||
// tool shadows its global name-twin for that agent, and a
|
||||
// restricted-away global tool is exactly as absent as a nonexistent
|
||||
// one — same UNKNOWN_TOOL result, no capability leak in the error.
|
||||
const tool = this.get(exec.name, exec.agent)
|
||||
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) {
|
||||
|
||||
@@ -295,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
|
||||
@@ -362,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', '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', '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) {
|
||||
|
||||
@@ -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)
|
||||
@@ -1018,6 +1175,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