Merge origin/master into skill system branch

Use the new filesystem seam for skill file reads and system skill writes when ctx.fs is available, and include the skill tool in the generated tool catalog.
This commit is contained in:
Yichen Jiang
2026-07-03 12:26:58 +08:00
138 changed files with 11548 additions and 248 deletions

View File

@@ -53,6 +53,8 @@ forever:
STEP loop:
drain steering
assembly = systemPrompt.assemble()
await serial agent/pre-step ⟵ surface mutation (compaction) outside the step
session('step/start')
request = waterfall agent/request
stream llm.stream(request) → session('assistant/chunk')
message = waterfall agent/step-result
@@ -74,8 +76,8 @@ 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/request`, `agent/step-result`, `tools/execute`, `agent/turn-continuation`
- Compaction: `agent/request`
- Hooks: `agent/pre-step`, `agent/request`, `agent/step-result`, `tools/execute`, `agent/turn-continuation`
- Compaction: `agent/pre-step`
- Sandbox, permission, plan mode: `tools/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.
- Persistence: `session/event` + `session/flush`

View File

@@ -12,6 +12,7 @@ import type { FinishReason, GenerateOptions, Message } from '@deepseek-ai/dsh-ll
import { BlockAssembler, HarnessError } from '@deepseek-ai/dsh-llm'
import type { Session, TurnEndReason, TurnTrigger } from '@deepseek-ai/dsh-session'
import { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt'
import type {} from '@deepseek-ai/dsh-tools'
import type { ReactLoopAgent } from './agent.ts'
@@ -147,10 +148,11 @@ export interface LoopHandle {
* drain queued → 'turn/start' → session('user/message'…) → emit agent/turn-start
* STEP loop:
* drain steering → session('steering/message') ⟵ catches late steering
* session('step/start'); emit agent/step-start ⟵ append before emit (the event-sourcing RFC)
* assembly = ctx.systemPrompt.assemble() ⟵ waterfall system-prompt/assemble
* await ctx.serial('agent/pre-step') ⟵ surface mutation (compaction) OUTSIDE the step
* session('step/start'); emit agent/step-start ⟵ append before emit (the event-sourcing RFC)
* req = {model, system, tools, messages: session.deriveMessages(), signal}
* req = waterfall agent/request ⟵ hooks/compaction/model-switch
* req = waterfall agent/request ⟵ hooks/model-switch
* stream ctx.llm.stream(req) ⟵ waterfall llm/stream (raw chunks)
* session('assistant/chunk'); emit agent/stream-chunk
* msg = waterfall agent/step-result ⟵ BEFORE the log append, so the
@@ -386,30 +388,78 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle,
// (or turn-start listeners on the first step) joins before the request.
drainSteering(ctx, 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
// async listener whose effect fires before we block — always has an armed
// abort to cancel against. isDisposed below covers disposal, which does
// NOT set the cancel marker. Cleared on every exit path below.
const abort = new AbortController()
handle.setAbort(abort)
// Assemble the system prompt for this step. Done HERE (before step/start)
// because the pre-step seam needs it: compaction measures token pressure
// against the system prompt (it counts toward the budget). runStep reuses
// this same assembly for the request, so the prompt is assembled once per
// step.
const assembly = await ctx.systemPrompt.assemble()
const fullSystemPrompt = [renderPrompt(assembly), agent.options.systemPrompt ?? '']
.filter(text => text.length > 0)
.join('\n\n')
// Interruption landing after assembly: dispose() or cancel() in a
// turn-start listener (or a listener whose promise resolved before the
// await above) arms either handle.isDisposed() or handle.isCancelled().
// The Abort was created first, so any concurrent abort also lands on it.
// Drop the about-to-start step WITHOUT running the seam — no step is open
// yet, so end the turn accordingly (disposed wins for an unambiguous
// reason).
if (handle.isCancelled() || handle.isDisposed()) {
handle.setAbort(undefined)
reason = handle.isDisposed() ? { kind: 'disposed' } : { kind: 'aborted', reason: handle.cancelReason() }
break
}
// 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
// replacement node land cleanly outside any step (honest structure that
// crash-safety relies on — a dangling `compact/start` sits before the
// synthetic `turn/end` repair appends). Serial (awaited, in order, no
// veto): each listener completes its surface mutation before the next, so
// concurrent listeners cannot interleave their `session.append`s. A
// throwing listener escapes to the outer catch, which closes the (not-yet-
// open) step as a no-op and ends the turn via failTurn — a broken
// pre-step plugin ends the turn, not the loop.
await ctx.serial('agent/pre-step', agent, turn, step, fullSystemPrompt, abort.signal)
// Interruption landing during the pre-step seam: do not open an empty
// step. `agent/step-start` listeners get their own check below because
// they necessarily run after step/start is appended/emitted.
if (handle.isCancelled() || handle.isDisposed()) {
handle.setAbort(undefined)
reason = handle.isDisposed() ? { kind: 'disposed' } : { kind: 'aborted', reason: handle.cancelReason() }
break
}
session.append('step/start', { turn, step })
stepOpen = true
ctx.emit('agent/step-start', agent, turn, step)
const abort = new AbortController()
handle.setAbort(abort)
// Cancel landing in the step-start window: a synchronous `agent/turn-start`
// or `agent/step-start` listener (both fire before this point) can have
// called `cancel()`, and `runStep` would otherwise run a full extra step
// with no AbortController having observed it. Check the marker AFTER
// setAbort (so the next-iteration drain sees a clean controller) and before
// `runStep`: drop the step, end the turn `aborted`. closeStep balances the
// already-appended step/start.
if (handle.isCancelled()) {
// Cancel landing in the step-start window: a synchronous
// `agent/step-start` listener can cancel after the step is already open.
// Check AFTER step/start append + emit and before `runStep`: drop the
// step, end the turn accordingly. closeStep balances the already-appended
// step/start.
if (handle.isCancelled() || handle.isDisposed()) {
handle.setAbort(undefined)
reason = { kind: 'aborted', reason: handle.cancelReason() }
reason = handle.isDisposed() ? { kind: 'disposed' } : { kind: 'aborted', reason: handle.cancelReason() }
closeStep()
break
}
let stepOutcome: { hadToolCalls: boolean; finish: FinishReason } | { error: Error }
try {
stepOutcome = await runStep(ctx, agent, turn, step, abort.signal)
stepOutcome = await runStep(ctx, agent, turn, step, assembly, fullSystemPrompt, abort.signal)
} catch (error: unknown) {
stepOutcome = { error: toError(error) }
} finally {
@@ -549,22 +599,22 @@ function drainSteering(ctx: Context, agent: ReactLoopAgent, turn: number): boole
return messages.length > 0
}
/** One step: assemble request → stream model → record → execute tools. */
/** One step: derive request from the (already pre-step-mutated) surface →
* stream model → record → execute tools. The caller assembles the system prompt
* and fires the `agent/pre-step` seam BEFORE opening the step, then passes the
* resulting `assembly`/`system` here, so the surface this step derives from
* already reflects any compaction. */
async function runStep(
ctx: Context,
agent: ReactLoopAgent,
turn: number,
step: number,
assembly: PromptAssembly,
system: string,
signal: AbortSignal,
): Promise<{ hadToolCalls: boolean; finish: FinishReason }> {
const { session, options } = agent
// --- Request assembly ---
const assembly = await ctx.systemPrompt.assemble()
const system = [renderPrompt(assembly), options.systemPrompt ?? '']
.filter(text => text.length > 0)
.join('\n\n')
let request: GenerateOptions = {
model: options.model ?? '',
messages: session.deriveMessages(),

View File

@@ -13,7 +13,7 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore, { TurnEndReason } from '@deepseek-ai/dsh-session'
import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
@@ -194,6 +194,73 @@ describe('Agent.cancel()', () => {
expect(reasons).toEqual([{ kind: 'aborted', reason: 'from turn-start' }])
})
it('cancel from a synchronous agent/step-start listener drops the step (post-step-start window)', async () => {
const adapter = new MockAdapter([textResponse('should not stream')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
// A step-start listener fires AFTER step/start is appended (and after the
// pre-step seam), so cancelling there lands in the SECOND cancel check (the
// one that must closeStep() to balance the already-open step) — distinct
// from a turn-start cancel, which is caught before the step opens.
let streamed = false
ctx.on('agent/stream-chunk', () => { streamed = true })
const dispose = ctx.on('agent/step-start', (subject) => {
if (subject === agent) agent.cancel('from step-start')
})
const reasons: TurnEndReason[] = []
ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason))
send(agent, 'go')
await waitForIdle(ctx, agent)
dispose()
// No step streamed, the turn ended aborted with the caller's reason, and the
// log is balanced (the open step was closed by the cancel branch).
expect(streamed).toBe(false)
expect(reasons).toEqual([{ kind: 'aborted', reason: 'from step-start' }])
const types = agent.session.events.map(e => e.type)
expect(types.filter(t => t === 'step/start').length).toBe(types.filter(t => t === 'step/end').length)
})
it('disposal from a synchronous agent/step-start listener closes the open step as disposed', 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-step-start'),
sessionId: SessionId('dispose-step-start-session'),
agentOptions: { model: 'mock' },
})
const agent = handle.agent as ReactLoopAgent
let disposalDone: Promise<void> | undefined
let streamed = false
ctx.on('agent/stream-chunk', () => { streamed = true })
ctx.on('agent/step-start', (subject) => {
if (subject === agent) disposalDone = handle.dispose()
})
send(agent, 'go')
await disposalDone
await agent.done
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' })
const types = agent.session.events.map(e => e.type)
expect(types.filter(t => t === 'step/start').length).toBe(types.filter(t => t === 'step/end').length)
})
it('cancel during the continuation window ends the turn aborted and runs no further step', async () => {
// A continuation-waterfall listener cancels DURING the continuation decision
// (the finished step's AbortController is already cleared), and votes to

View File

@@ -320,6 +320,110 @@ describe('agent loop', () => {
expect(adapter.requests[0]!.model).toBe('other-model')
})
it('agent/pre-step fires once per step before the step is opened', async () => {
// Two steps (a tool call, then a final text turn) → two model calls → two
// pre-step fires, each carrying the assembled full system prompt, BEFORE
// the step is opened and its request is derived (the request the adapter
// sees reflects any surface state at fire time).
const adapter = new MockAdapter([
toolCallResponse('c1', 'echo', {}, 'calling echo'),
textResponse('done'),
])
const ctx = await harness(adapter)
ctx.tools.register(defineTool({
name: 'echo', description: 'echo', parameters: {},
async execute() { return [{ type: 'text', text: 'echoed' }] },
}))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const fires: { turn: number; step: number; fullSystemPrompt: string }[] = []
ctx.on('agent/pre-step', (subject, turn, step, fullSystemPrompt) => {
if (subject === agent) fires.push({ turn, step, fullSystemPrompt })
})
send(agent, 'go')
await waitForIdle(ctx, agent)
// One fire per step, in order, each with the assembled system prompt.
expect(fires).toEqual([
{ turn: 1, step: 1, fullSystemPrompt: '' },
{ turn: 1, step: 2, fullSystemPrompt: '' },
])
})
it('agent/pre-step fires BEFORE the step it precedes opens (events land outside the step)', async () => {
// A listener appending a surface node in pre-step lands it BEFORE step/start
// in the log — proving the seam fires outside the step. The node is still in
// the derived request for that step (derive happens after step/start).
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
let injected = false
ctx.on('agent/pre-step', (subject) => {
if (subject === agent && !injected) {
injected = true
subject.session.append('context/message', {
content: [{ type: 'text', text: 'INJECTED-IN-PRE-STEP' }],
source: { kind: 'plugin', plugin: 'test' },
}, { surfaceOp: 'append' })
}
})
send(agent, 'go')
await waitForIdle(ctx, agent)
// The adapter's request includes the node injected during pre-step (derive
// reflects it).
const text = JSON.stringify(adapter.requests[0]!.messages)
expect(text).toContain('INJECTED-IN-PRE-STEP')
// And the injected event sits BEFORE the first step/start in the log —
// the seam fired outside the step.
const events = agent.session.events
const injectedSeq = events.find(e => e.type === 'context/message')!.seq
const firstStepStartSeq = events.find(e => e.type === 'step/start')!.seq
expect(injectedSeq).toBeLessThan(firstStepStartSeq)
})
it('a throwing agent/pre-step listener ends the turn (error), not the loop', async () => {
// The seam fires before step/start, so a throw escapes to runTurn's outer
// catch: the not-yet-open step closes as a no-op, the failure surfaces via
// agent/error, and the turn ends `error` (recorded on the durable turn/end).
// The loop survives and a follow-up prompt still runs.
const adapter = new MockAdapter([textResponse('second turn ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
let throwOnce = true
ctx.on('agent/pre-step', () => {
if (throwOnce) { throwOnce = false; throw new Error('boom in pre-step') }
})
const errors: Error[] = []
ctx.on('agent/error', (_a, _t, _s, error) => void errors.push(error))
send(agent, 'first')
await waitForIdle(ctx, agent)
// The first turn failed at step 1 (no model call happened), surfaced via
// agent/error, with the durable failure on turn/end.reason.
expect(errors).toHaveLength(1)
expect(errors[0]!.message).toContain('boom in pre-step')
expect(adapter.requests.length).toBe(0)
const firstTurnEnd = agent.session.events.find(e => e.type === 'turn/end')
expect(firstTurnEnd?.type === 'turn/end' && firstTurnEnd.data.reason).toMatchObject({ kind: 'error', step: 1 })
// The step opened-and-closed count stays balanced even though it never ran.
const types = agent.session.events.map(e => e.type)
expect(types.filter(t => t === 'step/start').length).toBe(types.filter(t => t === 'step/end').length)
// The loop survived: a second prompt runs a normal completed turn.
send(agent, 'second')
await waitForIdle(ctx, agent)
expect(adapter.requests.length).toBe(1)
const lastTurnEnd = agent.session.events.findLast(e => e.type === 'turn/end')
expect(lastTurnEnd?.type === 'turn/end' && lastTurnEnd.data.reason).toEqual({ kind: 'completed' })
})
it('cancel() mid-stream ends the turn with reason aborted', async () => {
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)

View File

@@ -1047,3 +1047,275 @@ describe('surface: assistant/message omits sourceEventSeqs when no chunks stream
expect(JSON.stringify(agent.session.deriveMessages())).toContain('injected')
})
})
describe('disposal/cancel honored during pre-step assembly (P1-1)', () => {
it('disposal during system-prompt assembly drops the about-to-start step as disposed', { timeout: 30000 }, async () => {
// Block `system-prompt/assemble` on a promise. Start disposal (which
// calls stop() synchronously, setting status=disposed), then release the
// block. The loop must check isDisposed() after assembly and end the turn
// `disposed` — no LLM call. Don't await fiber.dispose() before releasing
// the blocker: the dispose chain awaits agent.done, which hangs until the
// loop unblocks.
const adapter = new MockAdapter(['hang'])
let releaseAssemble!: () => void
const blocked = new Promise<void>(r => void (releaseAssemble = r))
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: [] })
await ctx.plugin(Invariants, { freeze: false })
ctx.llm.registerAdapter(['mock'], adapter)
// Blocking listener on the parent context (survives fiber disposal).
const unlisten = ctx.on('system-prompt/assemble', async function (_assembly, next) {
await blocked
return next()
})
let agent!: ReactLoopAgent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create(AgentId('a-dispose-assemble'), { model: 'mock' })
}, { inject: ['agentLoop'] }))
const reasons: TurnEndReason[] = []
ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason))
send(agent, 'go')
// Give the loop time to enter the step and reach assemble().
await new Promise(r => setTimeout(r, 50))
// Start disposal — stop() sets status=disposed synchronously, then the
// disposer's await agent.done hangs because the loop is blocked in the
// waterfall. Do NOT await yet; release the blocker first.
const disposalDone = fiber.dispose()
// Now release the blocked waterfall — the loop unblocks, checks
// isDisposed(), and exits, which resolves agent.done and disposalDone.
releaseAssemble()
await disposalDone
await agent.done
unlisten()
const e = [...agent.session.events]
expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1)
expect(e.filter(x => x.type === 'turn/end')).toHaveLength(1)
const turnEnd = e.findLast(x => x.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'disposed' })
// No step was opened, no LLM call was made.
expect(e.some(x => x.type === 'step/start')).toBe(false)
expect(e.some(x => x.type === 'assistant/chunk')).toBe(false)
// agent/turn-end may not fire when disposal happens during assembly: the
// fiber's disposer (stop→status=disposed) runs before closeTurn(true)'s
// emit, and the LIFO chain disposes effects in reverse registration order.
// The turn/end durable record is the one that matters.
})
it('cancel during system-prompt assembly drops the about-to-start step as aborted', { timeout: 30000 }, async () => {
const adapter = new MockAdapter([textResponse('should not appear')])
let releaseAssemble!: () => void
const blocker = new Promise<void>(r => void (releaseAssemble = r))
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: [] })
await ctx.plugin(Invariants, { freeze: false })
ctx.llm.registerAdapter(['mock'], adapter)
const unlisten = ctx.on('system-prompt/assemble', async function (_assembly, next) {
await blocker
return next()
})
let agent!: ReactLoopAgent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create(AgentId('a-cancel-assemble'), { model: 'mock' })
}, { inject: ['agentLoop'] }))
const reasons: TurnEndReason[] = []
ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason))
send(agent, 'go')
await new Promise(r => setTimeout(r, 50))
agent.cancel('user cancelled during assembly')
releaseAssemble()
await waitForIdle(ctx, agent)
await fiber.dispose()
await agent.done
unlisten()
const e = [...agent.session.events]
expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1)
expect(e.filter(x => x.type === 'turn/end')).toHaveLength(1)
const turnEnd = e.findLast(x => x.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({
kind: 'aborted',
reason: 'user cancelled during assembly',
})
expect(e.some(x => x.type === 'step/start')).toBe(false)
expect(e.some(x => x.type === 'assistant/chunk')).toBe(false)
expect(e.some(x => x.type === 'assistant/message')).toBe(false)
expect(adapter.requests).toHaveLength(0)
expect(reasons).toEqual([{ kind: 'aborted', reason: 'user cancelled during assembly' }])
})
it('disposal during agent/pre-step seam ends the turn disposed', { timeout: 15000 }, async () => {
// Block the `agent/pre-step` serial seam on a promise we control, then
// dispose the agent's fiber. When the block releases, the loop must see
// isDisposed() at the post-seam check and end the turn disposed.
const adapter = new MockAdapter(['hang'])
let releasePreStep!: () => void
const blocker = new Promise<void>(r => void (releasePreStep = r))
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: [] })
await ctx.plugin(Invariants, { freeze: false })
ctx.llm.registerAdapter(['mock'], adapter)
ctx.on('agent/pre-step', async () => {
await blocker
})
let agent!: ReactLoopAgent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create(AgentId('a-dispose-prestep'), { model: 'mock' })
}, { inject: ['agentLoop'] }))
const reasons: TurnEndReason[] = []
ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason))
send(agent, 'go')
await new Promise(r => setTimeout(r, 50))
// Start disposal, then release the block, then await disposal.
const disposalDone = fiber.dispose()
releasePreStep()
await disposalDone
await agent.done
// After the pre-step seam finishes, the post-seam cancel/dispose check
// catches disposal. The step was never opened, no LLM call was made.
const e = [...agent.session.events]
expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1)
expect(e.filter(x => x.type === 'turn/end')).toHaveLength(1)
const turnEnd = e.findLast(x => x.type === 'turn/end')
// Disposal wins the post-seam check — reason is `disposed`.
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'disposed' })
expect(e.some(x => x.type === 'step/start')).toBe(false)
expect(e.some(x => x.type === 'assistant/chunk')).toBe(false)
// agent/turn-end may not fire when disposal happens during pre-step: the
// fiber's disposer runs before closeTurn(true)'s emit. The durable turn/end
// is the authoritative record.
})
it('cancel during agent/pre-step seam ends the turn aborted', { timeout: 15000 }, async () => {
// Block `agent/pre-step`, then cancel() the agent. When the block releases,
// the post-seam check catches cancellation and ends the turn aborted.
const adapter = new MockAdapter(['hang'])
let releasePreStep!: () => void
const blocker = new Promise<void>(r => void (releasePreStep = r))
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: [] })
await ctx.plugin(Invariants, { freeze: false })
ctx.llm.registerAdapter(['mock'], adapter)
ctx.on('agent/pre-step', async () => {
await blocker
})
let agent!: ReactLoopAgent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create(AgentId('a-cancel-prestep'), { model: 'mock' })
}, { inject: ['agentLoop'] }))
const reasons: TurnEndReason[] = []
ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason))
send(agent, 'go')
await new Promise(r => setTimeout(r, 30))
agent.cancel('user cancelled')
releasePreStep()
await waitForIdle(ctx, agent)
await fiber.dispose()
await agent.done
const e = [...agent.session.events]
expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1)
expect(e.filter(x => x.type === 'turn/end')).toHaveLength(1)
const turnEnd = e.findLast(x => x.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted', reason: 'user cancelled' })
expect(e.some(x => x.type === 'step/start')).toBe(false)
expect(e.some(x => x.type === 'assistant/chunk')).toBe(false)
expect(reasons).toEqual([{ kind: 'aborted', reason: 'user cancelled' }])
})
it('disposal during assembly does not leak an LLM call or append assistant/chunk', { timeout: 15000 }, async () => {
// The key assertion from the original bug report: after disposal, no
// assistant/chunk or assistant/message appears — the turn ends disposed
// before any model interaction.
const adapter = new MockAdapter([textResponse('should not appear')])
let releaseAssemble!: () => void
const blocker = new Promise<void>(r => void (releaseAssemble = r))
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: [] })
await ctx.plugin(Invariants, { freeze: false })
ctx.llm.registerAdapter(['mock'], adapter)
ctx.on('system-prompt/assemble', async function (_assembly, next) {
await blocker
return next()
})
let agent!: ReactLoopAgent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create(AgentId('a-dispose-no-leak'), { model: 'mock' })
}, { inject: ['agentLoop'] }))
send(agent, 'go')
await new Promise(r => setTimeout(r, 50))
const disposalDone = fiber.dispose()
releaseAssemble()
await disposalDone
await agent.done
const e = [...agent.session.events]
expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1)
expect(e.filter(x => x.type === 'turn/end')).toHaveLength(1)
// The critical assertions: after disposal, the turn has no assistant
// artifacts — the turn ended disposed before the model was invoked.
expect(e.some(x => x.type === 'assistant/chunk')).toBe(false)
expect(e.some(x => x.type === 'assistant/message')).toBe(false)
expect(adapter.requests).toHaveLength(0)
// The durable turn/end reason is the authoritative record; agent/turn-end
// may not fire when disposal interleaves with closeTurn(true)'s emit.
})
})

View File

@@ -37,11 +37,12 @@ The full `agent/*` event taxonomy is declared via declaration merging in `dsh-ag
- `agent/turn-start`, `agent/turn-end` (carries `TurnEndReason`)
- `agent/step-start`, `agent/step-end`
#### Interception seams (waterfall)
#### Interception seams
- `agent/request` — mutate `GenerateOptions` before the model call (hooks, compaction, model switching, tool filtering)
- `agent/step-result` — post-process the assembled assistant message before tool dispatch (validates what the log records)
- `agent/turn-continuation` — override the continue/stop decision (force-continue /loop, force-stop budget guard)
- `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/request` (waterfall) — mutate `GenerateOptions` before the model call (hooks, model switching, tool filtering)
- `agent/step-result` (waterfall) — post-process the assembled assistant message before tool dispatch (validates what the log records)
- `agent/turn-continuation` (waterfall) — override the continue/stop decision (force-continue /loop, force-stop budget guard)
#### Streaming + tool (emit)

View File

@@ -179,11 +179,45 @@ declare module 'cordis' {
*/
'agent/step-end'(agent: Agent, turn: number, step: number): void
// ---- interception seams (waterfall) ----
// ---- step/request extension seams (serial + waterfall) ----
/**
* Awaited pre-step surface-mutation checkpoint, fired once per step AFTER
* `turn/start` (and after the prior step closed) but BEFORE this step's
* `step/start` — so anything a listener appends lands OUTSIDE the step,
* between `turn/start`/`step/end` and the upcoming `step/start`. `step` is
* the number of the step about to start. The loop awaits
* `ctx.serial('agent/pre-step', …)` after assembling the system prompt, then
* opens the step and derives the request history ONCE from whatever the
* surface now holds. This is where compaction belongs: it mutates the session
* surface in place (shadowing an older range with a summary node) with its
* log-only `compact/*` records cleanly outside any step, and the single
* subsequent derive reflects the mutation — so there is no double-derive and
* no listener can see (or be expected to act on) an assembled `messages`
* array that does not exist yet.
*
* Serial (awaited in registration order), not a waterfall: a listener
* mutates the surface as a side effect; there is nothing to transform, but
* the loop must wait for the mutation to complete before opening the step
* and deriving. Cordis `serial` bails early if a listener returns a bail
* 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
* summarization model call).
* @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
// reads. Revisit if no second consumer appears: e.g. hand listeners a lazy
// prompt provider, or move token-pressure measurement behind a
// compaction-specific seam instead of the shared pre-step checkpoint.
'agent/pre-step'(agent: Agent, turn: number, step: number, fullSystemPrompt: string, signal: AbortSignal): Promise<void> | void
/**
* Waterfall: mutate the fully-assembled {@link GenerateOptions} before the
* model call (hooks, compaction, model switching, tool filtering, …). Call
* `next()` to delegate, or return without it to short-circuit.
* model call (hooks, model switching, tool filtering, …). Call `next()` to
* delegate, or return without it to short-circuit. For surface mutation that
* must precede history derivation (compaction), use {@link agent/pre-step}
* instead — by the time this fires, `options.messages` is already derived.
* @mode waterfall
*/
'agent/request'(agent: Agent, turn: number, step: number, options: GenerateOptions, next: () => Promise<GenerateOptions>): Promise<GenerateOptions>

View File

@@ -57,7 +57,7 @@ Also defines `TurnTriggerMap` and `TurnEndReasonMap` (merge-extensible sum types
Every `SessionEvent` carries two optional top-level fields (structural metadata):
- `sourceEventSeqs?: number[]` — seq numbers of provenance sources (e.g., the `assistant/chunk` seqs behind an `assistant/message`, or the shadowed nodes behind a compaction marker).
- `sourceEventSeqs?: number[]` — seq numbers of provenance sources (e.g., the `assistant/chunk` seqs behind an `assistant/message`, or the shadowed nodes behind a compaction replace node).
- `surfaceOp?: SurfaceOp` — how this event entered the surface. Absent for non-surface events (boundaries, chunks, usage, errors).
### Metadata types (`types.ts`)
@@ -68,7 +68,7 @@ Every `SessionEvent` carries two optional top-level fields (structural metadata)
- Persistence plugins: subscribe to `session/event` (write-behind) and drain on `session/flush` (awaited) and fiber dispose. A durable backend reads the log and reloads it into a live session; the metadata seam (`SessionHeader`, `session.header`) is what such a backend stores beside the log.
- Replay/fork: `ctx.sessions.create(id, { seed })` seeds a new session with an existing event log. The surface rebuilds deterministically from `surfaceOp` markers in the seeded events. The seed is validated to the SAME invariants `append` enforces — including that every surface-eligible event (`SurfaceEventType`) carries a `surfaceOp` marker — so a marker-less message event is rejected at construction rather than silently vanishing from `deriveMessages()` (the surface is the sole derivation path) on resume.
- Compaction: a future plugin appends a new event with `surfaceOp: { op: 'replace', start, end }` to shadow old surface nodes.
- Compaction: the `dsh-compact-basic` plugin appends a `user/message` with `surfaceOp: { op: 'replace', start, end }` to shadow old surface nodes behind a summary checkpoint.
### What is NOT here (TODO)

View File

@@ -19,6 +19,7 @@ export { isJsonValue } from './json.ts'
export { interruptedTurnClosers } from './repair.ts'
export type { SurfaceNode } from './surface.ts'
export { isSurfaceEvent, isSurfaceEligibleType } from './surface.ts'
export { isToolPairingBalanced } from './tool-pairing.ts'
declare module 'cordis' {
interface Context {

View File

@@ -0,0 +1,100 @@
/**
* Tool-pairing balance over a session's SURFACE: is a given cut point in the
* surface a safe edge for a collapsed region (e.g. compaction)?
*
* The invariant a consumer needs: a collapsed region must never separate an
* `assistant/message`'s `tool-call` blocks from their answering `tool/result`s
* — that would leave the rehydrated transcript with a dangling tool-call or an
* orphaned tool-result, which every provider rejects. (This is the
* compaction-time mirror of the crash-recovery imbalance that
* {@link interruptedTurnClosers} repairs on load.) Steps were once used as a
* proxy for this bracketing, but a compaction REWRITES the surface — it lands a
* replacement node at a high log seq whose SURFACE position is the head — so a
* scan over the LOG's `step/*` markers mis-reads such a node's neighbours. The
* pairing the invariant actually protects lives in the surface nodes' own
* content (a `tool-call` block's id, a `tool/result`'s `callId`), which travels
* with the node through any reshaping, so alignment is decided over the surface
* directly.
*
* A **cut** is a gap between two adjacent surface nodes (named by the node it
* sits immediately before), or the after-tail gap (`null`). Walking the surface
* head→tail and assigning each node a delta — `+1` per `tool-call` block on an
* `assistant/message`, `-1` per `tool/result`, `0` otherwise — the depth at a
* cut is the number of still-unanswered tool calls before it. A cut is
* **balanced** when that depth is `0`. A region `[start..end]` is safe to
* collapse iff BOTH its edges are balanced cuts: the cut before `start` and the
* cut after `end`. Nodes that belong to no step (a pre-step `user/message`, an
* inter-step `steering/message`, an injection `context/message`) carry no
* pairing, contribute `0`, and so are free boundaries — exactly as before, but
* now as a consequence of the balance rather than a special case. An open
* trailing step (an assistant whose `tool/result`s have not landed yet) keeps
* the depth positive through the tail, so no cut inside it is balanced — the
* old explicit open-step check falls out of the same counter.
*
* @module @deepseek-ai/dsh-session/tool-pairing
*/
import type { SessionEvent } from './types.ts'
import type { SurfaceNode } from './surface.ts'
/**
* The tool-pairing delta of a surface node: how it shifts the count of
* unanswered tool calls. An `assistant/message` opens one bracket per
* `tool-call` block; a `tool/result` closes one; every other surface node
* (`user/message`, `context/message`, `steering/message`, a usage-only
* `assistant/message` with no tool-call blocks) is pairing-neutral.
*/
function nodeDelta(event: SessionEvent): number {
switch (event.type) {
case 'assistant/message':
return event.data.content.filter(block => block.type === 'tool-call').length
case 'tool/result':
return -1
// Non-pairing surface nodes and every non-surface event contribute nothing.
default:
return 0
}
}
/**
* Whether the surface prefix ending at the given cut has BALANCED tool-call /
* tool-result brackets — i.e. every `tool-call` block on the surface before the
* cut has its answering `tool/result` before the cut too, so the cut is a safe
* edge for a collapsed region (it cannot split an assistant↔result pair).
*
* `nodes` is the surface linked list in head→tail order (e.g.
* `session.surface.nodes`); `events` is the session log, used to look each
* node's event up by `seq`. `beforeSeq` names the cut by the surface node it
* sits immediately before; the after-tail cut (the whole surface) is `null`,
* as is any `beforeSeq` not present on the surface.
*
* A region `[start..end]` is collapsible iff both edges are balanced cuts: call
* `isToolPairingBalanced(nodes, events, start)` for the cut before `start`, and
* `isToolPairingBalanced(nodes, events, after)` — where `after` is `end`'s
* surface successor (`SurfaceNode.next`), or `null` when `end` is the tail —
* for the cut after `end`.
*
* @throws if the surface prefix drives the unanswered-call depth negative — a
* `tool/result` with no preceding open `tool-call` on the surface. That is a
* corrupt surface (a structural invariant violation), surfaced loudly here
* rather than silently mis-classifying a boundary.
*/
export function isToolPairingBalanced(
nodes: readonly SurfaceNode[],
events: readonly SessionEvent[],
beforeSeq: number | null,
): boolean {
let depth = 0
for (const node of nodes) {
if (node.seq === beforeSeq) return depth === 0
// node.seq is a surface-node seq, always a valid log index by construction.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
depth += nodeDelta(events[node.seq]!)
if (depth < 0) {
throw new Error(`tool-pairing balance: tool/result at surface seq ${node.seq} has no matching tool-call (corrupt surface)`)
}
}
// Reached the after-tail cut (beforeSeq === null, or a seq not on the
// surface): the whole-surface prefix is balanced iff depth returned to 0.
return depth === 0
}

View File

@@ -174,7 +174,8 @@ export interface TodoItem {
* same events; trace/telemetry = subscribe to the log.
*
* Merge-extensible: plugins declare extra event types via declaration merging
* (e.g. a compaction plugin adds `'compaction/marker'`).
* (e.g. the compaction plugin adds `'compact/start'`, `'compact/summary'`,
* `'compact/end'`).
*
* Durability contract (what a persistence backend relies on): the durable log
* persists every event verbatim, INCLUDING `assistant/chunk` — `seq` must stay
@@ -311,7 +312,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
/**
* Seq numbers of events that are provenance sources of this event
* (e.g. the `assistant/chunk` seqs that built an `assistant/message`,
* or the surface nodes shadowed by a compaction marker).
* or the surface nodes shadowed by a compaction replace node).
*/
sourceEventSeqs?: number[]
/** How this event entered the surface; absent for non-surface events. */

View File

@@ -278,6 +278,23 @@ describe('Session.append surface opts', () => {
// The string 'append' is a primitive — identity-preserving is fine.
expect(event.surfaceOp).toBe('append')
})
it('isSurfaceEvent rejects a surface-eligible type missing its surfaceOp marker', () => {
// A raw event (not built via append, which mandates the marker) of a
// surface-eligible type but with no surfaceOp must NOT narrow to a
// SurfaceEvent — it would otherwise be silently dropped from the surface.
const noMarker: SessionEvent = {
type: 'user/message', seq: 0, time: 1,
data: { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } },
}
expect(isSurfaceEvent(noMarker)).toBe(false)
// A non-surface type is rejected too (the type gate).
const boundary: SessionEvent = { type: 'turn/start', seq: 1, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }
expect(isSurfaceEvent(boundary)).toBe(false)
// A properly-marked surface event narrows.
const marked = { ...noMarker, surfaceOp: 'append' } as SurfaceEvent
expect(isSurfaceEvent(marked)).toBe(true)
})
})
describe('surface type guards', () => {

View File

@@ -0,0 +1,314 @@
import { describe, expect, it } from 'vitest'
import { CallId } from '@deepseek-ai/dsh-llm'
import { Session, SessionId, isToolPairingBalanced } from '../src/index.ts'
import type { SessionEvent, SurfaceNode } from '../src/index.ts'
/**
* Unit coverage for the tool-pairing balance check. It decides whether a CUT in
* the surface (a gap before a given surface node, or the after-tail gap) is a
* safe edge for a collapsed region (compaction): a region must never split an
* `assistant/message`'s tool-calls from their `tool/result`s. A cut is balanced
* when no unanswered tool-call sits before it on the surface. Nodes belonging to
* no step (pre-step user message, inter-step steering, injection context) are
* pairing-neutral, so their cuts are free boundaries.
*
* The fixtures are built through a real {@link Session} so the surface linked
* list is derived exactly as production does — including the non-monotonic
* surface a `replace` op leaves (a compaction checkpoint at a high log seq
* sitting at the surface head), which is the case the abandoned log-position
* scan mis-classified.
*
* Builders mirror the agent loop's real append order: queued user messages land
* BEFORE `step/start`; within a step the order is `assistant/message` then
* `tool/result`(s); injection turns are a bare `turn/start → context/message →
* turn/end` with no step.
*/
const SURFACE = { surfaceOp: 'append' as const }
/** Surface nodes + log for a session, the two args the balance check takes. */
function surfaceOf(session: Session): { nodes: readonly SurfaceNode[]; events: readonly SessionEvent[] } {
return { nodes: session.surface.nodes, events: session.events }
}
/** The cut BEFORE the surface node at `seq` is balanced (safe region start). */
function startBalanced(session: Session, seq: number): boolean {
const { nodes, events } = surfaceOf(session)
return isToolPairingBalanced(nodes, events, seq)
}
/** The cut AFTER the surface node at `seq` is balanced (safe region end). */
function endBalanced(session: Session, seq: number): boolean {
const { nodes, events } = surfaceOf(session)
const node = nodes.find(n => n.seq === seq)
if (!node) throw new Error(`seq ${seq} is not a surface node`)
return isToolPairingBalanced(nodes, events, node.next)
}
/** Surface seq of the nth (0-based) event of a given type. */
function seqOf(s: Session, type: SessionEvent['type'], nth = 0): number {
return s.events.filter(e => e.type === type)[nth]!.seq
}
/** A closed turn with one closed step holding an assistant + its tool result. */
function toolStepSession(): Session {
const s = new Session(SessionId('tool-step'))
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
s.append('user/message', { content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }, SURFACE)
s.append('step/start', { turn: 1, step: 1 })
s.append('assistant/message', {
turn: 1, step: 1,
content: [
{ type: 'text', text: 'calling' },
{ type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' },
],
}, SURFACE)
s.append('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'bash', arguments: '{}' })
s.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'out' }], isError: false }, SURFACE)
s.append('step/end', { turn: 1, step: 1 })
s.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
return s
}
describe('isToolPairingBalanced — region START (cut before a node)', () => {
it('is true for a pre-step user/message (belongs to no step)', () => {
const s = toolStepSession()
expect(startBalanced(s, seqOf(s, 'user/message'))).toBe(true)
})
it('is true for the first surface node of a step (the assistant/message)', () => {
// The cut before the assistant is balanced — nothing unanswered precedes it.
const s = toolStepSession()
expect(startBalanced(s, seqOf(s, 'assistant/message'))).toBe(true)
})
it('is false for a tool/result whose assistant/message precedes it in the same step', () => {
// The cut before the tool/result has one unanswered tool-call (the
// assistant's) → starting the region here would orphan that call.
const s = toolStepSession()
expect(startBalanced(s, seqOf(s, 'tool/result'))).toBe(false)
})
it('is true at the surface head (nothing precedes)', () => {
const s = new Session(SessionId('lone'))
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
s.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, SURFACE)
expect(startBalanced(s, seqOf(s, 'user/message'))).toBe(true)
})
})
describe('isToolPairingBalanced — region END (cut after a node)', () => {
it('is true for the last surface node of a closed step (the tool/result)', () => {
// After the tool/result the assistant's single call is answered → balanced.
const s = toolStepSession()
expect(endBalanced(s, seqOf(s, 'tool/result'))).toBe(true)
})
it('is false for an assistant/message with a later tool/result in the same step', () => {
// After the assistant its tool-call is still unanswered → ending here strands
// the result.
const s = toolStepSession()
expect(endBalanced(s, seqOf(s, 'assistant/message'))).toBe(false)
})
it('is true for a pre-step user/message', () => {
const s = toolStepSession()
expect(endBalanced(s, seqOf(s, 'user/message'))).toBe(true)
})
it('is false at the tail when the node is inside an open (unclosed) step', () => {
// step/start then an assistant tool-call, but no tool/result yet (mid-flight).
// The after-tail cut still has one unanswered call → not balanced.
const s = new Session(SessionId('open-step'))
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
s.append('step/start', { turn: 1, step: 1 })
s.append('assistant/message', {
turn: 1, step: 1,
content: [{ type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }],
}, SURFACE)
expect(endBalanced(s, seqOf(s, 'assistant/message'))).toBe(false)
})
it('is true at the tail when the node is a trailing inter-step node (step already closed)', () => {
// A steering message appended after step/end, at the tail. The prior step's
// pair is balanced and steering is neutral → the after-tail cut is balanced.
const s = new Session(SessionId('trailing-steer'))
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
s.append('step/start', { turn: 1, step: 1 })
s.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'a' }] }, SURFACE)
s.append('step/end', { turn: 1, step: 1 })
s.append('steering/message', { turn: 1, content: [{ type: 'text', text: 's' }], source: { kind: 'user' } }, SURFACE)
expect(endBalanced(s, seqOf(s, 'steering/message'))).toBe(true)
})
it('is true at the tail when no step ever opened', () => {
const s = new Session(SessionId('no-step'))
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
s.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, SURFACE)
expect(endBalanced(s, seqOf(s, 'user/message'))).toBe(true)
})
})
describe('isToolPairingBalanced — multiple tool calls in one assistant message', () => {
// An assistant message with two tool-calls needs BOTH results before the cut
// after it is balanced — depth +2, then -1, -1.
function twoCallStep(): Session {
const s = new Session(SessionId('two-call'))
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
s.append('step/start', { turn: 1, step: 1 })
s.append('assistant/message', {
turn: 1, step: 1,
content: [
{ type: 'tool-call', id: CallId('c1'), name: 'a', arguments: '{}' },
{ type: 'tool-call', id: CallId('c2'), name: 'b', arguments: '{}' },
],
}, SURFACE)
s.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: '1' }], isError: false }, SURFACE)
s.append('tool/result', { turn: 1, step: 1, callId: CallId('c2'), content: [{ type: 'text', text: '2' }], isError: false }, SURFACE)
s.append('step/end', { turn: 1, step: 1 })
s.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
return s
}
it('is unbalanced after the first of two results (one call still open)', () => {
const s = twoCallStep()
expect(endBalanced(s, seqOf(s, 'tool/result', 0))).toBe(false)
})
it('is balanced after the second result (both calls answered)', () => {
const s = twoCallStep()
expect(endBalanced(s, seqOf(s, 'tool/result', 1))).toBe(true)
})
})
describe('isToolPairingBalanced — a mid-step injection context/message', () => {
// A background task-done inject() lands a context/message INSIDE an open step,
// between the assistant (with a tool-call) and its tool/result. It is
// pairing-neutral, so the cut on EITHER side of it is unbalanced (the call is
// still open across it) — it is NOT a free boundary in this position.
function midStepInjection(): Session {
const s = new Session(SessionId('mid-inject'))
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
s.append('step/start', { turn: 1, step: 1 })
s.append('assistant/message', {
turn: 1, step: 1,
content: [{ type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }],
}, SURFACE)
s.append('context/message', { content: [{ type: 'text', text: 'bg task done' }], source: { kind: 'plugin', plugin: 'tool-bash' } }, SURFACE)
s.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'out' }], isError: false }, SURFACE)
s.append('step/end', { turn: 1, step: 1 })
s.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
return s
}
it('start cut before the mid-step context/message is unbalanced (call still open)', () => {
const s = midStepInjection()
expect(startBalanced(s, seqOf(s, 'context/message'))).toBe(false)
})
it('end cut after the mid-step context/message is unbalanced (call still open)', () => {
const s = midStepInjection()
expect(endBalanced(s, seqOf(s, 'context/message'))).toBe(false)
})
})
describe('isToolPairingBalanced on an injection turn (no step)', () => {
// An idle inject() wraps a context/message in a bare turn/start →
// context/message → turn/end with NO step. The context node is a free boundary
// both ways (pairing-neutral, nothing open around it).
function injectionSession(): Session {
const s = new Session(SessionId('injection'))
s.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: { kind: 'user' } } })
s.append('context/message', { content: [{ type: 'text', text: 'ctx' }], source: { kind: 'user' } }, SURFACE)
s.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
return s
}
it('start: balanced', () => {
const s = injectionSession()
expect(startBalanced(s, seqOf(s, 'context/message'))).toBe(true)
})
it('end: balanced', () => {
const s = injectionSession()
expect(endBalanced(s, seqOf(s, 'context/message'))).toBe(true)
})
})
describe('isToolPairingBalanced — CBR-001: a head checkpoint left by a replace op', () => {
// The case the log-position scan got wrong. After a compaction, a replacement
// user/message lands at a HIGH log seq but sits at the SURFACE head, beside
// the still-open step whose events follow it in the log. It carries no
// tool-call/result pair (just summarized prose), so it must be a balanced cut
// on BOTH sides regardless of its log neighbours.
function checkpointHeadedSession(): Session {
const s = new Session(SessionId('checkpoint'))
// A closed turn with a tool step → surface [u1, asst(call), result].
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
s.append('step/start', { turn: 1, step: 1 })
s.append('user/message', { content: [{ type: 'text', text: 'u1' }], source: { kind: 'user' } }, SURFACE)
s.append('assistant/message', {
turn: 1, step: 1,
content: [{ type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }],
}, SURFACE)
s.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'out' }], isError: false }, SURFACE)
s.append('step/end', { turn: 1, step: 1 })
s.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
// An OPEN turn whose step is in progress (loop fires compaction here).
s.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
s.append('step/start', { turn: 2, step: 1 })
// Compaction replaces the whole turn-1 surface ([u1, asst, result]) with one
// summary user/message — appended now, so it carries a high log seq.
const u1 = seqOf(s, 'user/message')
const result = s.events.find(e => e.type === 'tool/result')!.seq
s.append('user/message', {
content: [{ type: 'text', text: 'CHECKPOINT' }],
source: { kind: 'plugin', plugin: 'compact' },
}, { surfaceOp: { op: 'replace', start: u1, end: result } })
// The step's own assistant/message lands AFTER the checkpoint in the log,
// still inside the open step.
s.append('assistant/message', { turn: 2, step: 1, content: [{ type: 'text', text: 'a2' }] }, SURFACE)
return s
}
it('the head checkpoint sits at the surface head while a later surface node follows it in the log', () => {
const s = checkpointHeadedSession()
const nodes = s.surface.nodes
const checkpointSeq = nodes[0]!.seq
// The checkpoint heads the surface, yet a surface node (the open step's
// assistant) follows it in LOG order — the exact split between surface
// position and log position that the log-position scan tripped on.
const laterSurfaceInLog = s.events.find(
e => e.seq > checkpointSeq && nodes.some(n => n.seq === e.seq),
)
expect(laterSurfaceInLog).toBeDefined()
expect(nodes[0]!.seq).toBe(checkpointSeq)
})
it('start cut before the head checkpoint is balanced (it is the head)', () => {
const s = checkpointHeadedSession()
expect(startBalanced(s, s.surface.nodes[0]!.seq)).toBe(true)
})
it('end cut after the head checkpoint is balanced (it carries no tool pair)', () => {
// This is the exact assertion the log-position scan failed: the forward log
// scan from the checkpoint reached the open step's assistant/message and
// wrongly reported mid-step. The surface balance sees a neutral node whose
// following cut closes no open call.
const s = checkpointHeadedSession()
expect(endBalanced(s, s.surface.nodes[0]!.seq)).toBe(true)
})
})
describe('isToolPairingBalanced — corrupt surface guard', () => {
it('throws when a tool/result has no preceding tool-call (depth goes negative)', () => {
// A surface that opens with a tool/result (no assistant call before it) is
// structurally corrupt — surfaced loudly rather than mis-classified.
const s = new Session(SessionId('corrupt'))
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
s.append('step/start', { turn: 1, step: 1 })
s.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'x' }], isError: false }, SURFACE)
const { nodes, events } = surfaceOf(s)
expect(() => isToolPairingBalanced(nodes, events, null)).toThrow(/no matching tool-call/)
})
})

View File

@@ -23,6 +23,7 @@
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-fs": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
@@ -31,6 +32,8 @@
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-fs": "workspace:^",
"@deepseek-ai/dsh-fs-local": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"cordis": "^4.0.0-rc.6"
}

View File

@@ -13,6 +13,7 @@ import { dirname, join, resolve } from 'node:path'
import { homedir } from 'node:os'
import { Context, Service } from 'cordis'
import { parse as parseYaml } from 'yaml'
import type { FileSystem, FsTarget } from '@deepseek-ai/dsh-fs'
import type { GenerateOptions } from '@deepseek-ai/dsh-llm'
import type {} from '@deepseek-ai/dsh-agent'
@@ -249,18 +250,13 @@ export class SkillService extends Service {
}
async function writeSystemSkills(systemRoot: string, ctx: Context): Promise<void> {
await mkdir(systemRoot, { recursive: true })
await Promise.all(SYSTEM_SKILLS.map(async (skill) => {
const dir = join(systemRoot, skill.name)
const file = join(dir, 'SKILL.md')
try {
await access(file)
if (await skillFileExists(ctx, file)) {
return
} catch {
// Expected first-run path: the bundled system skill has not been installed.
}
await mkdir(dir, { recursive: true })
await writeFile(file, renderSkillFile(skill))
await writeSkillText(ctx, file, renderSkillFile(skill))
ctx.logger.debug(`installed system skill ${skill.name} at ${file}`)
}))
}
@@ -300,10 +296,8 @@ async function discoverRoot(root: SkillRoot, ctx: Context): Promise<SkillDefinit
}
async function parseSkillFile(path: string, directory: string, source: SkillSource, ctx: Context): Promise<SkillDefinition | undefined> {
let raw: string
try {
raw = await readFile(path, 'utf8')
} catch {
const raw = await readSkillText(ctx, path)
if (raw === undefined) {
return undefined
}
const parsed = parseFrontmatter(raw)
@@ -334,6 +328,63 @@ async function parseSkillFile(path: string, directory: string, source: SkillSour
}
}
function optionalFileSystem(ctx: Context): FileSystem | undefined {
return ctx.get('fs')
}
async function skillFileExists(ctx: Context, path: string): Promise<boolean> {
const fs = optionalFileSystem(ctx)
if (fs !== undefined) {
const target = await fs.resolve(path)
return await fs.stat(target) !== undefined
}
try {
await access(path)
return true
} catch {
// Expected first-run path: the bundled system skill has not been installed.
return false
}
}
async function writeSkillText(ctx: Context, path: string, content: string): Promise<void> {
const fs = optionalFileSystem(ctx)
if (fs !== undefined) {
await fs.writeText(await fs.resolve(path), content)
return
}
await mkdir(dirname(path), { recursive: true })
await writeFile(path, content)
}
async function readSkillText(ctx: Context, path: string): Promise<string | undefined> {
const fs = optionalFileSystem(ctx)
if (fs !== undefined) {
return await readSkillTextFromFileSystem(ctx, fs, path)
}
try {
return await readFile(path, 'utf8')
} catch {
return undefined
}
}
async function readSkillTextFromFileSystem(ctx: Context, fs: FileSystem, path: string): Promise<string | undefined> {
const target = await fs.resolve(path)
const info = await fs.stat(target)
if (info === undefined || info.type !== 'file') return undefined
try {
return await fs.readText(target)
} catch (error) {
ctx.logger.warn(`skill file ${path} ignored: ${fsReadErrorMessage(target, error)}`)
return undefined
}
}
function fsReadErrorMessage(target: FsTarget, error: unknown): string {
return `failed to read text file at ${target.displayPath}: ${errorMessage(error)}`
}
function parseFrontmatter(raw: string): { data: Record<string, unknown>; body: string } | undefined {
if (!raw.startsWith('---\n')) return undefined
const end = raw.indexOf('\n---', 4)

View File

@@ -4,6 +4,7 @@ import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { Context } from 'cordis'
import SkillService from '@deepseek-ai/dsh-skill'
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
async function tempDir(name: string): Promise<string> {
return await import('node:fs/promises').then(fs => fs.mkdtemp(join(tmpdir(), `dsh-${name}-`)))
@@ -194,6 +195,24 @@ describe('SkillService', () => {
expect(await readFile(join(home, '.dsh/skills/.system/dsh-skill-creator/SKILL.md'), 'utf8')).toContain('dsh-skill-creator')
})
it('uses the filesystem service when installing bundled system skills', async () => {
const home = await tempDir('skill-install-fs')
const existing = join(home, '.dsh/skills/.system/dsh-plugin-creator/SKILL.md')
await mkdir(join(home, '.dsh/skills/.system/dsh-plugin-creator'), { recursive: true })
await writeFile(existing, '---\nname: dsh-plugin-creator\ndescription: Existing system skill\n---\n\nExisting body.\n')
const ctx = new Context()
await ctx.plugin(LocalFileSystem, { cwd: home })
await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents') })
expect((await ctx.skills.list()).map(skill => [skill.name, skill.description])).toEqual([
['dsh-plugin-creator', 'Existing system skill'],
['dsh-skill-creator', 'Create or update DeepSeek Harness SKILL.md instructions.'],
])
expect(await readFile(existing, 'utf8')).toContain('Existing body.')
expect(await readFile(join(home, '.dsh/skills/.system/dsh-skill-creator/SKILL.md'), 'utf8')).toContain('dsh-skill-creator')
})
it('renders bundled system skill files with and without routing metadata', async () => {
const home = await tempDir('skill-install-render')
@@ -205,6 +224,26 @@ describe('SkillService', () => {
expect(await readFile(join(home, '.dsh/skills/.system/dsh-skill-creator/SKILL.md'), 'utf8')).toContain('whenToUse:')
})
it('uses the filesystem service for skill file reads when it is available', async () => {
const home = await tempDir('skill-read-fs')
const root = join(home, '.dsh/skills')
await writeFlatSkill(root, 'text-skill', 'Text skill', 'Text body.')
await mkdir(join(root, 'empty-dir'), { recursive: true })
await mkdir(join(root, 'directory-skill/SKILL.md'), { recursive: true })
await writeFile(join(root, 'binary-skill.md'), Buffer.concat([
Buffer.from('---\nname: binary-skill\ndescription: Binary skill\n---\n\n'),
Buffer.from([0xff]),
Buffer.from('\n'),
]))
const ctx = new Context()
await ctx.plugin(LocalFileSystem, { cwd: home })
await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false })
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['text-skill'])
expect(await ctx.skills.get('binary-skill')).toBeUndefined()
})
it('degrades when bundled system skill installation fails', async () => {
const home = await tempDir('skill-install-fail')
await writeFile(join(home, '.dsh'), 'not a directory')

View File

@@ -8,6 +8,7 @@
"references": [
{ "path": "../../../vendor/cosmokit" },
{ "path": "../../../vendor/cordis" },
{ "path": "../../fs/fs" },
{ "path": "../../llm/llm" },
{ "path": "../agent" }
]

View File

@@ -34,4 +34,4 @@ Merge-extensible: plugins can declare extra fields on `PromptAssembly` via decla
### What is NOT here
- Any hardcoded prompt text — every section comes from plugins.
- Prompt compaction (belongs on the `agent/request` seam in `dsh-agent`).
- Prompt compaction (belongs on the `agent/pre-step` seam in `dsh-agent`).

View File

@@ -8,7 +8,7 @@ Tool registry and execution waterfall. Tool plugins register their schemas and e
- `ctx.tools.register(definition: ToolDefinition): () => void` Register a tool. Disposed with the calling fiber.
- `ctx.tools.get(name: string): ToolDefinition | undefined`
- `ctx.tools.schemas(): ToolSchema[]` Schemas of all registered tools (without the `execute` functions).
- `ctx.tools.schemas(): ToolSchema[]` Schemas of all registered tools (without the `execute` functions). The shipped tools' schemas are catalogued in [docs/tool-catalog/tools.md](../../../docs/tool-catalog/tools.md), generated by booting each tool plugin and harvesting this method (see [the tool-schema-catalog RFC](../../../docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md)).
- `ctx.tools.execute(exec: ToolExecution): Promise<ToolExecutionResult>` Execute one tool call through the `tools/execute` waterfall.
### Injected services
@@ -72,7 +72,7 @@ See `defineTool`, `validateArgs`, `ToolArgsError`, `SchemaSpec`, `InferArgs`, an
A tool owns how ITS calls render in a UI (an editor's tool-call card, a CLI log line) — a UI plugin must NOT special-case tool names. A `ToolDefinition` may declare two optional, pure, display-only methods:
- `presentCall(args): ToolCallPresentation | undefined` — the PENDING state: a human-readable `title` (always-visible label), an optional `kind` (`read`/`edit`/`execute`/… for icon/treatment, default `other`), an optional `rawInput` (the salient input to show in a detail view — e.g. a shell command as a string, NOT the whole args object), an optional `content` (UI content shown alongside the title/card — e.g. a bash `description` as a text block above the terminal card), and an optional `terminal` (a neutral `{ cwd? }` asking a capable UI to render this call as a TERMINAL, e.g. for `bash`).
- `presentCall(args): ToolCallPresentation | undefined` — the PENDING state: a human-readable `title` (always-visible label), an optional `kind` (`read`/`edit`/`execute`/… for icon/treatment, default `other`), an optional `rawInput` (the salient input to show in a detail view — e.g. a shell command as a string, NOT the whole args object), an optional `content` (UI content shown alongside the title/card — e.g. a bash `description` as a text block above the terminal card), an optional `locations` (`{ path, line? }[]` — the files this call reads/modifies, so a capable UI can follow along / jump to them; the ACP bridge forwards them as `tool_call.locations`), and an optional `terminal` (a neutral `{ cwd? }` asking a capable UI to render this call as a TERMINAL, e.g. for `bash`).
- `presentResult(args, result): ToolResultPresentation | undefined` — the COMPLETED state, given the same `args` and the `{ content, isError }` result: an optional replacement `title`, reformatted `content` (e.g. wrap command output in a fenced ` ```console ` block — a UI-only affordance that must NOT appear in the model-facing `execute` result), and an optional `terminal` (the `{ output?, exitCode?, signal? }` for a terminal-rendered call). The `ToolTerminal` shape is provider-neutral; a UI bridge (the ACP bridge) maps it to a terminal card (with an exit-status pill) and a UI that can't ignores it and uses `content`.
Returning `undefined` (or omitting a method) tells a UI to fall back to a generic presentation (title = tool name, raw args as input, raw result content). Both methods must be **pure and side-effect-free**: a UI may call them during live streaming AND during a session-log replay, so they depend only on their arguments. With `defineTool`, `args` is the typed `InferArgs<S>` shape; the helper soft-validates before calling (a malformed/older logged arg shape yields `undefined` rather than throwing, since display must never crash a replay). The shapes are provider-neutral — the ACP bridge (`dsh-acp`) maps them to ACP `tool_call`/`tool_call_update` wire fields, and `dsh-tool-bash` is the reference implementation.

View File

@@ -109,6 +109,16 @@ export interface ToolCallPresentation {
* {@link terminal} block (if any) as a terminal card.
*/
content?: ContentBlock[]
/**
* Files this call reads or modifies, so a capable UI can "follow along" —
* highlight or jump to the file (and line) as the tool runs. Provider-neutral
* `{ path, line? }` pairs; a UI bridge maps them to its own affordance (the ACP
* bridge forwards them as `tool_call.locations`). `path` is what the tool
* operated on (the model-facing path); `line` is an optional 1-based line to
* focus (e.g. a read's offset). Omit for a call that touches no file (e.g.
* `bash`).
*/
locations?: { path: string; line?: number }[]
/**
* Ask a capable UI to render this call as a TERMINAL (a command running in a
* working directory), not a generic tool card — set by a tool whose call IS a

View File

@@ -0,0 +1,117 @@
/**
* Guarantee tests for the tool-schema catalog generator
* (`scripts/gen-tool-catalog.ts`).
*
* The generated catalog is frozen by a regenerate-and-diff freshness gate, so
* the freshness half is exercised by `pnpm run verify-tool-catalog` in CI. What
* a freshness diff CANNOT prove is (a) that BOOTING the tool plugins yields the
* shipped schema — the whole reason this generator boots instead of parsing
* source (a runtime-spread enum resolves to its literal members) — and (b) that
* the completeness guard REJECTS a tool package missing from the boot manifest,
* the property that replaces the AST pass's "nothing silently omitted". These
* tests drive the exported `collectToolCatalog` / `assertManifestComplete` /
* `render` directly, mirroring the negative-path style of the cordis-catalog
* generator tests.
*/
import { describe, expect, it } from 'vitest'
import {
assertManifestComplete,
collectToolCatalog,
render,
type ToolCatalog,
} from '../../../../scripts/gen-tool-catalog.ts'
/** JSON Schema shape enough to reach the values AST extraction can't. */
interface JsonSchema {
type: string
properties?: Record<string, JsonSchema>
items?: JsonSchema
enum?: string[]
required?: string[]
}
describe('gen-tool-catalog collectToolCatalog', () => {
it('boots every shipped tool package and harvests its model-facing schemas', async () => {
const catalog = await collectToolCatalog()
const names = catalog.flatMap(entry => entry.schemas.map(s => s.name)).sort()
expect(names).toEqual(['bash', 'bash_kill', 'bash_output', 'edit', 'read', 'skill', 'subagent', 'todo_write', 'write'])
// Every tool carries a JSON-Schema `parameters` object (what the model sees).
for (const entry of catalog) {
for (const schema of entry.schemas) {
expect((schema.parameters as unknown as JsonSchema).type).toBe('object')
}
}
})
it('resolves a runtime-spread enum to its literal members (the payoff over AST)', async () => {
const catalog = await collectToolCatalog()
const todo = catalog
.flatMap(entry => entry.schemas)
.find(s => s.name === 'todo_write')
// `todo-todo` writes `enum: [...STATUSES]` — a source AST would see the
// spread, not the values. Booting yields the shipped enum literals.
const status = (((todo?.parameters as unknown as JsonSchema).properties?.todos)?.items)?.properties?.status
expect(status?.enum).toEqual(['pending', 'in_progress', 'completed'])
})
it('attributes each package with a source pointer that names its index', async () => {
const catalog = await collectToolCatalog()
const bash = catalog.find(entry => entry.pkg === '@deepseek-ai/dsh-tool-bash')
expect(bash?.source).toBe('packages/bash/tool-bash/src/index.ts')
})
it('records the shipped `subagent_fork` alias in a note (config-driven tool name)', async () => {
// `tool-subagent`'s registered name is the load-time `toolName` config, so
// the shipped agents surface this one package as both `subagent` and
// `subagent_fork`. Booting yields only the default name; the note is how a
// reader learns the fork alias the model also sees. Without it the catalog
// would silently under-report the shipped tool surface.
const catalog = await collectToolCatalog()
const subagent = catalog.find(entry => entry.pkg === '@deepseek-ai/dsh-tool-subagent')
expect(subagent?.schemas.map(s => s.name)).toEqual(['subagent'])
expect(subagent?.note).toMatch(/subagent_fork/)
})
})
describe('gen-tool-catalog assertManifestComplete', () => {
it('passes when the manifest lists every on-disk tool package (the default)', () => {
expect(() => { assertManifestComplete() }).not.toThrow()
})
it('throws, naming the omitted package, when a tool package is missing from the manifest', () => {
// An empty manifest scanned against the real tree: every `tool-*` package
// is unlisted, so the guard must fire and name them.
expect(() => { assertManifestComplete([]) }).toThrow(/not in the boot manifest/)
expect(() => { assertManifestComplete([]) }).toThrow(/tool-bash/)
})
})
describe('gen-tool-catalog render', () => {
it('emits a package heading, a tool heading, and a json schema fence', () => {
const catalog: ToolCatalog = [
{
pkg: '@deepseek-ai/dsh-tool-demo',
source: 'packages/demo/tool-demo/src/index.ts',
schemas: [{ name: 'demo', description: 'A demo tool.', parameters: { type: 'object', properties: {} } }],
},
]
const md = render(catalog)
expect(md).toContain('## `@deepseek-ai/dsh-tool-demo`')
expect(md).toContain('### `demo`')
expect(md).toContain('A demo tool.')
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',
schemas: [{ name: 'demo', description: '', parameters: { type: 'object', properties: {} }, strict: true }],
},
]
expect(render(catalog)).toContain('Strict: `true`')
})
})