Merge branch 'codex/invariant-service-seam' into codex/invariant-package-registration-gate
This commit is contained in:
@@ -50,7 +50,9 @@ Configured agents start automatically. A model call requires both `provider` and
|
||||
|
||||
### Internal concrete driver
|
||||
|
||||
The concrete `Agent` class, its `Inbox`, `runLoop`, and instance-bound publication/start controls are package-internal. The package root exports only the plugin/service/config contract, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than naming, constructing, or starting driver internals. The concrete `send()`, running `steer()`, and open-turn `inject()` materialize content plus resolved source once as detached, deeply frozen lossless JSON; malformed data throws before enqueue or append. An injection that arrives while the current step executes assistant tool calls stays in a FIFO until the batch settles; successful batches place it after the complete result batch, and interrupted batches drain it before the turn closes. One prepared session can be claimed by only one concrete driver, and everything observable happens through session events and the `agent/*` event taxonomy.
|
||||
The concrete `Agent` class, its `Inbox`, `runLoop`, and instance-bound publication/start controls are package-internal. The package root exports only the plugin/service/config contract, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than naming, constructing, or starting driver internals. One prepared session can be claimed by only one concrete driver, and everything observable happens through session events and the `agent/*` event taxonomy.
|
||||
|
||||
Each concrete `send()` materializes content plus resolved source once as a detached, deeply frozen lossless-JSON FIFO item. If claimed, it is the sole ordinary message in its turn; a successor waits for the preceding ordinary turn's checkpoint to settle, while cancellation, disposal, or a pre-start failure may drop it without a turn. Running `steer()` enters the steering FIFO: an open turn records it at the next steering checkpoint before a request or continuation decision, but policy can still stop before another step; steering left after turn close and its checkpoint becomes later queued input unless terminal turn policy, cancellation, or disposal discards it. Open-turn `inject()` uses the same accepted-value boundary but defers in a FIFO while the current step executes assistant tool calls; successful batches place it after all results, and interrupted batches drain it before turn close. Malformed data throws before enqueue or append.
|
||||
|
||||
### Loop lifecycle (`loop.ts`)
|
||||
|
||||
|
||||
@@ -252,7 +252,6 @@ export class ReactLoopAgent implements Agent {
|
||||
const context = {
|
||||
content,
|
||||
source,
|
||||
...options?.envelope !== undefined ? { envelope: options.envelope } : {},
|
||||
...options?.meta !== undefined ? { meta: options.meta } : {},
|
||||
}
|
||||
if (isTurnOpen(this.session)) {
|
||||
@@ -398,7 +397,7 @@ export class ReactLoopAgent implements Agent {
|
||||
cancelReason: () => this.cancelReason,
|
||||
clearCancel: () => { this.cancelRequested = false },
|
||||
withToolBatch: run => this.withToolBatch(run),
|
||||
// Pre-step cancellation re-parks without emitting a status transition.
|
||||
// Pre-start cancellation settles queued-work waiters before publishing idle.
|
||||
settleIdle: () => { this.settleIdleWaiters() },
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ export interface InboxMessage {
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-agent inbox: a queued FIFO (drained at turn start) and a steering FIFO
|
||||
* Per-agent inbox: a queued FIFO (dequeued once per turn start) and a steering FIFO
|
||||
* (drained between steps of a running turn). Purely an in-memory mechanism of
|
||||
* the loop — the public surface is `Agent.send()` / `Agent.steer()`.
|
||||
*/
|
||||
@@ -54,11 +54,11 @@ export class Inbox {
|
||||
}
|
||||
|
||||
/**
|
||||
* Drain all queued messages (turn start).
|
||||
* @returns the drained messages in arrival order; the queued FIFO is left empty.
|
||||
* Remove the oldest queued message for one turn start.
|
||||
* @returns the oldest message, or `undefined` when the queued FIFO is empty.
|
||||
*/
|
||||
drainQueued(): InboxMessage[] {
|
||||
return this.queuedMessages.splice(0)
|
||||
dequeueQueued(): InboxMessage | undefined {
|
||||
return this.queuedMessages.shift()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -72,7 +72,7 @@ export class Inbox {
|
||||
/**
|
||||
* Discard all pending messages (queued + steering) without delivering them —
|
||||
* used by `cancel()`, which drops un-started work rather than draining it into
|
||||
* a turn. Unlike `drainQueued`/`drainSteering`, the messages are thrown away.
|
||||
* a turn. Unlike `dequeueQueued`/`drainSteering`, the messages are thrown away.
|
||||
*/
|
||||
clear(): void {
|
||||
this.queuedMessages.length = 0
|
||||
|
||||
@@ -91,16 +91,16 @@ export interface LoopHandle {
|
||||
cancelReason(): string
|
||||
/** Clear the cancel marker (called once per iteration after the turn returns). */
|
||||
clearCancel(): void
|
||||
/** Settle idle waiters when pre-running cancellation skips a turn, without emitting `agent/status`. */
|
||||
/** Settle idle waiters before pre-running cancellation publishes idle. */
|
||||
settleIdle(): void
|
||||
/** Run an active tool-call batch, accepting post-tool context into the FIFO drained before settlement. */
|
||||
readonly withToolBatch: <T>(run: (acceptContext: (context: HookContext) => void) => Promise<T>) => Promise<T>
|
||||
}
|
||||
|
||||
/**
|
||||
* Drive queued batches as durable turns until disposal. Plugin failures end the
|
||||
* current turn without terminating the driver. The caller establishes the
|
||||
* `ctx.agents.withInitiator()` boundary before entry; package-private
|
||||
* Drive queued messages as independent durable turns until disposal. Plugin
|
||||
* failures end the current turn without terminating the driver. The caller
|
||||
* establishes the `ctx.agents.withInitiator()` boundary before entry; package-private
|
||||
* orchestration recovers that exact Agent and captures its Session locally.
|
||||
* @param ctx - the plugin context the loop reaches its initiating Agent,
|
||||
* events (agent/…, session/flush), and services (systemPrompt, llm, tools)
|
||||
@@ -118,20 +118,35 @@ export async function runLoop(ctx: Context, handle: LoopHandle): Promise<void> {
|
||||
const events = agentEvents(ctx, agent)
|
||||
|
||||
while (!handle.isDisposed()) {
|
||||
await handle.inbox.waitForQueued(handle.disposed)
|
||||
if (handle.isDisposed()) break
|
||||
|
||||
// Cancellation between wake and `running` skips only the cancelled work;
|
||||
// a replacement prompt still runs and owns the eventual idle transition.
|
||||
// An idle listener can enqueue and cancel replacement work before the next
|
||||
// wait is installed. Consume that empty marker before parking the driver.
|
||||
if (handle.isCancelled()) {
|
||||
handle.clearCancel()
|
||||
if (!handle.inbox.hasQueued) {
|
||||
handle.settleIdle()
|
||||
handle.setStatus('idle')
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
await handle.inbox.waitForQueued(handle.disposed)
|
||||
if (handle.isDisposed()) break
|
||||
|
||||
// Cancellation between wake and `running` skips only the cancelled work;
|
||||
// a replacement prompt still runs before the eventual idle transition.
|
||||
if (handle.isCancelled()) {
|
||||
handle.clearCancel()
|
||||
if (!handle.inbox.hasQueued) {
|
||||
// Settle before publishing idle: the already-idle path has no status
|
||||
// transition, while an idle listener can register waiters for new work.
|
||||
handle.settleIdle()
|
||||
handle.setStatus('idle')
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
handle.setStatus('running')
|
||||
if (handle.isDisposed()) break
|
||||
|
||||
// A synchronous `running` listener can cancel before `runTurn`; balance the
|
||||
// status only when no replacement prompt was queued by that listener.
|
||||
@@ -182,12 +197,11 @@ async function runTurn(
|
||||
return messages.length > 0
|
||||
}
|
||||
|
||||
// Drain before opening the turn, but append only after `turn/start`.
|
||||
const queued = handle.inbox.drainQueued()
|
||||
const first = queued[0]
|
||||
// Claim one queued message before opening its turn, but append it only after `turn/start`.
|
||||
const message = handle.inbox.dequeueQueued()
|
||||
/* v8 ignore next 3 -- invariant guard: runLoop only calls runTurn when hasQueued */
|
||||
if (!first) throw new Error('runTurn invariant violated: no queued message at turn start')
|
||||
const trigger: TurnTrigger = { kind: 'message', source: first.source }
|
||||
if (!message) throw new Error('runTurn invariant violated: no queued message at turn start')
|
||||
const trigger: TurnTrigger = { kind: 'message', source: message.source }
|
||||
|
||||
let reason: TurnEndReason = { kind: 'completed' }
|
||||
let step = 0
|
||||
@@ -226,56 +240,36 @@ async function runTurn(
|
||||
// matter what throws below; the catch + closeTurn guarantee it. A pre-commit
|
||||
// veto leaves no turn/start in the log and therefore owes no turn/end.
|
||||
session.append('turn/start', { turn, trigger })
|
||||
// Each drained queued message runs the `agent/prompt-submit` waterfall before
|
||||
// it becomes a `user/message` — a hook can rewrite the prompt or block it.
|
||||
// The claimed message runs the `agent/prompt-submit` waterfall before it
|
||||
// becomes a `user/message` — a hook can rewrite the prompt or block it.
|
||||
// Recorded INSIDE the turn (after turn/start) so every event is turn-enclosed;
|
||||
// turn/end is now owed, so a throwing prompt-submit listener (the waterfall
|
||||
// throws) is caught below and the turn still closes.
|
||||
let anyAllowed = false
|
||||
// Seeded with a floor (only observable if the batch were empty, which
|
||||
// runTurn never allows — it is called with ≥1 queued message); each `block`
|
||||
// decision carries a required `reason` and overwrites it, so a fully-blocked
|
||||
// batch always reports the last vetoing reason.
|
||||
let lastBlockReason = 'prompt blocked by hook'
|
||||
for (const message of queued) {
|
||||
const decision = await events.waterfall(
|
||||
'agent/prompt-submit', message.content, message.source,
|
||||
() => Promise.resolve<PromptDecision>({ kind: 'allow' }),
|
||||
)
|
||||
if (decision.kind === 'block') {
|
||||
lastBlockReason = decision.reason
|
||||
// Record the veto durably: `PromptDecision.reason` is the durable record
|
||||
// of why a prompt was blocked, but a fully-blocked batch's `rejected`
|
||||
// turn/end only preserves the LAST reason, and a MIXED batch (this prompt
|
||||
// blocked, another allowed) does not end `rejected` at all — so without
|
||||
// this append a blocked prompt would vanish from the log whenever any
|
||||
// sibling prompt is allowed. `prompt/blocked` sits in the open turn in
|
||||
// place of the `user/message` this prompt would have become.
|
||||
session.append('prompt/blocked', { content: message.content, source: message.source, reason: decision.reason })
|
||||
continue
|
||||
}
|
||||
anyAllowed = true
|
||||
const promptDecision = await events.waterfall(
|
||||
'agent/prompt-submit', message.content, message.source,
|
||||
() => Promise.resolve<PromptDecision>({ kind: 'allow' }),
|
||||
)
|
||||
if (promptDecision.kind === 'block') {
|
||||
session.append('prompt/blocked', { content: message.content, source: message.source, reason: promptDecision.reason })
|
||||
reason = { kind: 'rejected', reason: promptDecision.reason }
|
||||
} else {
|
||||
// `allow.content` REPLACES the prompt bytes (a rewrite); absent keeps them.
|
||||
const content = decision.content ?? message.content
|
||||
const content = promptDecision.content ?? message.content
|
||||
session.append('user/message', { content, source: message.source }, { surfaceOp: 'append' })
|
||||
// Every `allow.additionalContexts` entry is a separate context/message the
|
||||
// next request also sees. The turn is open, so inject() appends each one
|
||||
// into THIS turn without flattening provenance, framing, or metadata.
|
||||
for (const context of decision.additionalContexts ?? []) {
|
||||
// into THIS turn without flattening provenance or metadata.
|
||||
for (const context of promptDecision.additionalContexts ?? []) {
|
||||
agent.inject(context.content, {
|
||||
source: context.source,
|
||||
...context.envelope !== undefined ? { envelope: context.envelope } : {},
|
||||
...context.meta !== undefined ? { meta: context.meta } : {},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
while (true) {
|
||||
// A fully blocked batch closes its zero-step turn as rejected.
|
||||
if (!anyAllowed) {
|
||||
reason = { kind: 'rejected', reason: lastBlockReason }
|
||||
break
|
||||
}
|
||||
// A blocked prompt closes its zero-step turn as rejected.
|
||||
if (promptDecision.kind === 'block') break
|
||||
step += 1
|
||||
|
||||
// Steering from the previous round's continuation listeners joins before
|
||||
|
||||
@@ -79,7 +79,8 @@ describe('Agent.cancel()', () => {
|
||||
|
||||
// send() queues synchronously (status still idle, loop microtask not yet
|
||||
// resumed). Cancel in that pre-step window: the queued turn must not run.
|
||||
send(agent, 'drop me')
|
||||
send(agent, 'drop me first')
|
||||
send(agent, 'drop me second')
|
||||
agent.cancel('pre-step')
|
||||
|
||||
// Give the loop a chance to wake and process the cancel.
|
||||
@@ -91,6 +92,35 @@ describe('Agent.cancel()', () => {
|
||||
expect(agent.status).toBe('idle')
|
||||
})
|
||||
|
||||
it('disposal from the running notification drops queued work before turn start', async () => {
|
||||
const adapter = new MockAdapter([textResponse('should not run')])
|
||||
const ctx = await harness(adapter)
|
||||
const handle = await ctx.agents.create({
|
||||
sessionId: SessionId('dispose-running-session'),
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
const agent = handle.agent
|
||||
|
||||
const running = Promise.withResolvers<undefined>()
|
||||
let disposalDone: Promise<void> | undefined
|
||||
ctx.on('agent/status', (subject, status) => {
|
||||
if (subject !== agent || status !== 'running') return
|
||||
disposalDone = handle.dispose()
|
||||
running.resolve(undefined)
|
||||
})
|
||||
|
||||
send(agent, 'drop before claim')
|
||||
await running.promise
|
||||
if (disposalDone === undefined) throw new Error('running listener did not start disposal')
|
||||
await disposalDone
|
||||
await driverDone(agent)
|
||||
|
||||
expect(agent.status).toBe('disposed')
|
||||
expect(agent.session.events.some(event => event.type === 'turn/start')).toBe(false)
|
||||
expect(userTexts(agent)).toEqual([])
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('a whenIdle() waiter registered BEFORE a pre-step cancel resolves (F1 hang guard)', async () => {
|
||||
const adapter = new MockAdapter([textResponse('x')])
|
||||
const ctx = await harness(adapter)
|
||||
@@ -110,7 +140,162 @@ describe('Agent.cancel()', () => {
|
||||
expect(agent.status).toBe('idle')
|
||||
})
|
||||
|
||||
it('cancel() mid-step aborts the in-flight model call; the turn ends aborted', async () => {
|
||||
it('cancel() between consecutive turns restores idle and leaves idle steer usable', async () => {
|
||||
const adapter = new MockAdapter([textResponse('first reply'), textResponse('steer reply')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('between-turn-cancel'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
let rejectFirstFlush = true
|
||||
ctx.on('session/flush', (session) => {
|
||||
if (session !== agent.session || !rejectFirstFlush) return
|
||||
rejectFirstFlush = false
|
||||
throw new Error('first flush failed')
|
||||
})
|
||||
|
||||
const cancelled = Promise.withResolvers<undefined>()
|
||||
ctx.on('agent/error', (subject, _turn, _step, error) => {
|
||||
if (subject !== agent || error.message !== 'first flush failed') return
|
||||
// The first hop runs before runLoop resumes from runTurn; the second lands
|
||||
// before its resolved waitForQueued continuation checks cancellation.
|
||||
queueMicrotask(() => {
|
||||
queueMicrotask(() => {
|
||||
agent.cancel('between turns')
|
||||
cancelled.resolve(undefined)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
const statuses: string[] = []
|
||||
ctx.on('agent/status', (subject, status) => {
|
||||
if (subject === agent) statuses.push(status)
|
||||
})
|
||||
|
||||
send(agent, 'first')
|
||||
send(agent, 'queued tail')
|
||||
await cancelled.promise
|
||||
|
||||
expect(agent.status).toBe('idle')
|
||||
expect(statuses).toEqual(['running', 'idle'])
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
|
||||
expect(userTexts(agent)).toEqual(['first'])
|
||||
|
||||
let idleResolved = false
|
||||
void agent.whenIdle().then(() => { idleResolved = true })
|
||||
await Promise.resolve()
|
||||
expect(idleResolved).toBe(true)
|
||||
|
||||
const idle = waitForIdle(ctx, agent)
|
||||
agent.steer([{ type: 'text', text: 'idle steer' }])
|
||||
await idle
|
||||
|
||||
expect(statuses).toEqual(['running', 'idle', 'running', 'idle'])
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
expect(userTexts(agent)).toEqual(['first', 'idle steer'])
|
||||
})
|
||||
|
||||
it('an idle-listener replacement keeps whenIdle pending until the replacement turn finishes', async () => {
|
||||
const adapter = new MockAdapter([textResponse('first reply'), textResponse('replacement reply')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('between-turn-idle-listener'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
let rejectFirstFlush = true
|
||||
ctx.on('session/flush', (session) => {
|
||||
if (session !== agent.session || !rejectFirstFlush) return
|
||||
rejectFirstFlush = false
|
||||
throw new Error('first flush failed')
|
||||
})
|
||||
|
||||
ctx.on('agent/error', (subject, _turn, _step, error) => {
|
||||
if (subject !== agent || error.message !== 'first flush failed') return
|
||||
queueMicrotask(() => {
|
||||
queueMicrotask(() => { agent.cancel('between turns') })
|
||||
})
|
||||
})
|
||||
|
||||
const replacementRegistered = Promise.withResolvers<undefined>()
|
||||
let replacementObservation: Promise<{ status: string; requests: number; turns: number }> | undefined
|
||||
ctx.on('agent/status', (subject, status) => {
|
||||
if (subject !== agent || status !== 'idle' || replacementObservation !== undefined) return
|
||||
send(agent, 'replacement')
|
||||
replacementObservation = agent.whenIdle().then(() => ({
|
||||
status: agent.status,
|
||||
requests: adapter.requests.length,
|
||||
turns: agent.session.events.filter(event => event.type === 'turn/start').length,
|
||||
}))
|
||||
replacementRegistered.resolve(undefined)
|
||||
})
|
||||
|
||||
send(agent, 'first')
|
||||
send(agent, 'cancelled tail')
|
||||
await replacementRegistered.promise
|
||||
if (replacementObservation === undefined) throw new Error('idle listener did not register replacement work')
|
||||
|
||||
await expect(replacementObservation).resolves.toEqual({ status: 'idle', requests: 2, turns: 2 })
|
||||
expect(userTexts(agent)).toEqual(['first', 'replacement'])
|
||||
})
|
||||
|
||||
it('idle-listener cancellation settles its waiter without cancelling later work', async () => {
|
||||
const adapter = new MockAdapter([textResponse('first reply'), textResponse('later reply')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('idle-listener-cancel'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
const replacementRegistered = Promise.withResolvers<undefined>()
|
||||
let replacementObservation: Promise<{ status: string; requests: number; turns: number }> | undefined
|
||||
ctx.on('agent/status', (subject, status) => {
|
||||
if (subject !== agent || status !== 'idle' || replacementObservation !== undefined) return
|
||||
send(agent, 'cancelled replacement')
|
||||
replacementObservation = agent.whenIdle().then(() => ({
|
||||
status: agent.status,
|
||||
requests: adapter.requests.length,
|
||||
turns: agent.session.events.filter(event => event.type === 'turn/start').length,
|
||||
}))
|
||||
agent.cancel('idle listener')
|
||||
replacementRegistered.resolve(undefined)
|
||||
})
|
||||
|
||||
send(agent, 'first')
|
||||
await replacementRegistered.promise
|
||||
if (replacementObservation === undefined) throw new Error('idle listener did not register replacement work')
|
||||
|
||||
await expect(Promise.race([
|
||||
replacementObservation,
|
||||
new Promise((_resolve, reject) => setTimeout(() => { reject(new Error('whenIdle hung after idle-listener cancel')) }, 1000)),
|
||||
])).resolves.toEqual({ status: 'idle', requests: 1, turns: 1 })
|
||||
|
||||
const idle = waitForIdle(ctx, agent)
|
||||
send(agent, 'later')
|
||||
await idle
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
expect(userTexts(agent)).toEqual(['first', 'later'])
|
||||
})
|
||||
|
||||
it('replacement work queued after idle-listener cancellation still runs', async () => {
|
||||
const adapter = new MockAdapter([textResponse('first reply'), textResponse('replacement reply')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('idle-listener-post-cancel-send'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
const replacementRegistered = Promise.withResolvers<undefined>()
|
||||
let replacementIdle: Promise<void> | undefined
|
||||
ctx.on('agent/status', (subject, status) => {
|
||||
if (subject !== agent || status !== 'idle' || replacementIdle !== undefined) return
|
||||
send(agent, 'cancelled replacement')
|
||||
agent.cancel('idle listener')
|
||||
send(agent, 'surviving replacement')
|
||||
replacementIdle = agent.whenIdle()
|
||||
replacementRegistered.resolve(undefined)
|
||||
})
|
||||
|
||||
send(agent, 'first')
|
||||
await replacementRegistered.promise
|
||||
if (replacementIdle === undefined) throw new Error('idle listener did not register replacement work')
|
||||
await replacementIdle
|
||||
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
expect(userTexts(agent)).toEqual(['first', 'surviving replacement'])
|
||||
})
|
||||
|
||||
it('cancel() mid-step aborts the active turn and drops every queued tail item', async () => {
|
||||
const adapter = new MockAdapter(['hang'])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
@@ -121,10 +306,14 @@ describe('Agent.cancel()', () => {
|
||||
send(agent, 'go')
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
expect(agent.status).toBe('running')
|
||||
send(agent, 'queued tail')
|
||||
agent.cancel('mid-step')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(reasons).toEqual([{ kind: 'aborted', reason: 'mid-step' }])
|
||||
expect(userTexts(agent)).toEqual(['go'])
|
||||
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('cancel() with no reason defaults to "cancelled" when aborting an in-flight step', async () => {
|
||||
|
||||
@@ -642,15 +642,20 @@ describe('plugin exceptions are contained', () => {
|
||||
expect(agent.status).toBe('idle')
|
||||
})
|
||||
|
||||
it('a rejecting session/flush listener is reported but does not kill the agent', async () => {
|
||||
it('a rejecting first-turn flush settles before the queued tail starts', async () => {
|
||||
const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
let rejectedOnce = false
|
||||
ctx.on('session/flush', async () => {
|
||||
if (!rejectedOnce) {
|
||||
rejectedOnce = true
|
||||
const firstFlush = Promise.withResolvers<undefined>()
|
||||
const releaseFirstFlush = Promise.withResolvers<undefined>()
|
||||
let flushes = 0
|
||||
ctx.on('session/flush', async (session) => {
|
||||
if (session !== agent.session) return
|
||||
flushes += 1
|
||||
if (flushes === 1) {
|
||||
firstFlush.resolve(undefined)
|
||||
await releaseFirstFlush.promise
|
||||
throw new Error('disk full')
|
||||
}
|
||||
})
|
||||
@@ -658,18 +663,25 @@ describe('plugin exceptions are contained', () => {
|
||||
const errors: Error[] = []
|
||||
ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error))
|
||||
|
||||
const idle = waitForIdle(ctx, agent)
|
||||
send(agent, 'first')
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(errors.map(e => e.message)).toEqual(['disk full'])
|
||||
|
||||
send(agent, 'second')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
await firstFlush.promise
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
|
||||
|
||||
releaseFirstFlush.resolve(undefined)
|
||||
await idle
|
||||
|
||||
expect(errors.map(e => e.message)).toEqual(['disk full'])
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('disposed status is part of the agent/status contract', () => {
|
||||
it('disposing the fiber emits agent/status(disposed) and ends the turn with reason disposed', async () => {
|
||||
it('disposing the fiber ends the active turn and never starts its queued tail', async () => {
|
||||
const adapter = new MockAdapter(['hang'])
|
||||
const ctx = await harness(adapter)
|
||||
|
||||
@@ -685,11 +697,19 @@ describe('disposed status is part of the agent/status contract', () => {
|
||||
|
||||
send(agent, 'go')
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
send(agent, 'queued tail')
|
||||
await fiber.dispose()
|
||||
await driverDone(agent)
|
||||
|
||||
expect(statuses).toEqual(['running', 'disposed'])
|
||||
expect(reasons).toEqual([{ kind: 'disposed' }])
|
||||
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
|
||||
const messages = agent.session.events
|
||||
.filter(event => event.type === 'user/message')
|
||||
.flatMap(event => event.data.content)
|
||||
.flatMap(block => block.type === 'text' ? [block.text] : [])
|
||||
expect(messages).toEqual(['go'])
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('a throwing agent/status listener cannot break disposal or leak the registry entry', async () => {
|
||||
|
||||
@@ -146,12 +146,22 @@ describe('toError normalization', () => {
|
||||
const errors: Error[] = []
|
||||
ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error))
|
||||
|
||||
send(agent, 'go')
|
||||
send(agent, 'fails before turn start')
|
||||
send(agent, 'survives as the next item')
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(errors).toHaveLength(1)
|
||||
expect(errors[0]).toMatchObject({ message: 'naked string error', code: 'UNKNOWN' })
|
||||
expect(adapter.requests).toEqual([])
|
||||
expect(agent.session.events.some(event => event.type === 'turn/start' || event.type === 'turn/end')).toBe(false)
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
const starts = agent.session.events.filter(event => event.type === 'turn/start')
|
||||
const ends = agent.session.events.filter(event => event.type === 'turn/end')
|
||||
const messages = agent.session.events.filter(event => event.type === 'user/message')
|
||||
expect(starts).toHaveLength(1)
|
||||
expect(starts[0]?.type === 'turn/start' && starts[0].data.turn).toBe(1)
|
||||
expect(ends).toHaveLength(1)
|
||||
expect(messages).toHaveLength(1)
|
||||
expect(messages[0]?.type === 'user/message' && messages[0].data.content).toEqual([
|
||||
{ type: 'text', text: 'survives as the next item' },
|
||||
])
|
||||
})
|
||||
|
||||
it('normalizes non-Error throws from agent/request waterfall via inline toError in runStep catch', async () => {
|
||||
|
||||
@@ -8,17 +8,17 @@ function resolverPair() {
|
||||
}
|
||||
|
||||
describe('Inbox', () => {
|
||||
it('enqueues and drains queued messages in FIFO order', () => {
|
||||
it('dequeues one queued message at a time in FIFO order', () => {
|
||||
const inbox = new Inbox()
|
||||
inbox.enqueue({ content: [{ type: 'text', text: 'first' }], source: { kind: 'user' } })
|
||||
inbox.enqueue({ content: [{ type: 'text', text: 'second' }], source: { kind: 'user' } })
|
||||
expect(inbox.hasQueued).toBe(true)
|
||||
|
||||
const drained = inbox.drainQueued()
|
||||
expect(drained).toHaveLength(2)
|
||||
expect(drained[0]!.content[0]).toMatchObject({ text: 'first' })
|
||||
expect(drained[1]!.content[0]).toMatchObject({ text: 'second' })
|
||||
expect(inbox.dequeueQueued()?.content[0]).toMatchObject({ text: 'first' })
|
||||
expect(inbox.hasQueued).toBe(true)
|
||||
expect(inbox.dequeueQueued()?.content[0]).toMatchObject({ text: 'second' })
|
||||
expect(inbox.hasQueued).toBe(false)
|
||||
expect(inbox.dequeueQueued()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('pushes and drains steering messages separately from queued', () => {
|
||||
|
||||
@@ -99,7 +99,6 @@ describe('agent/prompt-submit', () => {
|
||||
additionalContexts: [{
|
||||
content: [{ type: 'text', text: '<system-reminder>extra ctx</system-reminder>' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
envelope: 'raw',
|
||||
meta,
|
||||
}],
|
||||
}))
|
||||
@@ -113,7 +112,6 @@ describe('agent/prompt-submit', () => {
|
||||
expect(userMsg).toBeDefined()
|
||||
expect(ctxMsg?.type === 'context/message' && ctxMsg.data.content).toEqual([{ type: 'text', text: '<system-reminder>extra ctx</system-reminder>' }])
|
||||
expect(ctxMsg?.type === 'context/message' && ctxMsg.data.source).toEqual({ kind: 'plugin', plugin: 'test' })
|
||||
expect(ctxMsg?.type === 'context/message' && ctxMsg.data.envelope).toBe('raw')
|
||||
expect(ctxMsg?.type === 'context/message' && ctxMsg.data.meta).toEqual(meta)
|
||||
const sent = JSON.stringify(adapter.requests[0]!.messages)
|
||||
expect(sent).toContain('extra ctx')
|
||||
@@ -179,9 +177,7 @@ describe('agent/prompt-submit', () => {
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'rejected', reason: 'blocked by policy' })
|
||||
})
|
||||
|
||||
it('a mixed batch records a prompt/blocked for the vetoed prompt while the allowed one runs', async () => {
|
||||
// Blocking one prompt in a mixed batch must persist its reason even though
|
||||
// the allowed prompt keeps the turn from ending rejected.
|
||||
it('adjacent blocked and allowed prompts keep independent turn outcomes', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ran once')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
@@ -194,13 +190,13 @@ describe('agent/prompt-submit', () => {
|
||||
const reasons: TurnEndReason[] = []
|
||||
ctx.on('session/event', (_s, event: SessionEvent) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
|
||||
|
||||
// both sends land before the loop drains → one batched turn
|
||||
// Both sends land before the driver wakes, but each remains its own turn.
|
||||
send(agent, 'secret')
|
||||
send(agent, 'safe')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const log = events(agent)
|
||||
// the allowed prompt became a user/message and drove exactly one model call
|
||||
// The allowed prompt became a user/message and drove exactly one model call.
|
||||
const userMsgs = log.filter(e => e.type === 'user/message')
|
||||
expect(userMsgs).toHaveLength(1)
|
||||
expect(userMsgs[0]?.type === 'user/message' && userMsgs[0].data.content).toEqual([{ type: 'text', text: 'safe' }])
|
||||
@@ -212,12 +208,14 @@ describe('agent/prompt-submit', () => {
|
||||
content: [{ type: 'text', text: 'secret' }],
|
||||
reason: 'policy: no secrets',
|
||||
})
|
||||
// the turn did NOT reject — a sibling was allowed — so the boundary reason
|
||||
// alone would not have preserved the block
|
||||
expect(reasons.some(r => r.kind === 'rejected')).toBe(false)
|
||||
expect(log.filter(e => e.type === 'turn/start')).toHaveLength(2)
|
||||
expect(reasons).toEqual([
|
||||
{ kind: 'rejected', reason: 'policy: no secrets' },
|
||||
{ kind: 'completed' },
|
||||
])
|
||||
})
|
||||
|
||||
it('a throwing prompt-submit listener ends the turn balanced (error), loop survives', async () => {
|
||||
it('a throwing prompt-submit listener ends its turn balanced while an adjacent message survives', async () => {
|
||||
const adapter = new MockAdapter([textResponse('after')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
@@ -228,20 +226,31 @@ describe('agent/prompt-submit', () => {
|
||||
return { kind: 'allow' as const }
|
||||
})
|
||||
const errors: Error[] = []
|
||||
const reasons: TurnEndReason[] = []
|
||||
const statuses: string[] = []
|
||||
ctx.on('agent/error', (_a, _t, _s, error) => void errors.push(error))
|
||||
ctx.on('agent/status', (subject, status) => { if (subject === agent) statuses.push(status) })
|
||||
ctx.on('session/event', (session, event) => {
|
||||
if (session === agent.session && event.type === 'turn/end') reasons.push(event.data.reason)
|
||||
})
|
||||
|
||||
const idle = waitForIdle(ctx, agent)
|
||||
send(agent, 'first')
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(errors.map(e => e.message)).toEqual(['prompt hook broke'])
|
||||
// turn balanced
|
||||
const log = events(agent)
|
||||
expect(log.filter(e => e.type === 'turn/start')).toHaveLength(1)
|
||||
expect(log.filter(e => e.type === 'turn/end')).toHaveLength(1)
|
||||
|
||||
// loop survives: a second prompt runs normally
|
||||
send(agent, 'second')
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(adapter.requests.length).toBeGreaterThanOrEqual(1)
|
||||
await idle
|
||||
expect(errors.map(e => e.message)).toEqual(['prompt hook broke'])
|
||||
// The failed prompt forms one balanced error turn; the adjacent prompt forms
|
||||
// the following normal turn without an intermediate idle transition.
|
||||
const log = events(agent)
|
||||
expect(log.filter(e => e.type === 'turn/start')).toHaveLength(2)
|
||||
expect(log.filter(e => e.type === 'turn/end')).toHaveLength(2)
|
||||
expect(reasons).toEqual([
|
||||
{ kind: 'error', step: 0, message: 'prompt hook broke' },
|
||||
{ kind: 'completed' },
|
||||
])
|
||||
expect(statuses).toEqual(['running', 'idle'])
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('second')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -556,7 +565,6 @@ describe('tool additionalContexts buffering across a step', () => {
|
||||
additionalContexts: [{
|
||||
content: [{ type: 'text', text: `ctx-${exec.callId}` }],
|
||||
source: { kind: 'plugin', plugin: 'p' },
|
||||
envelope: 'raw',
|
||||
meta: { callId: exec.callId },
|
||||
}],
|
||||
}))
|
||||
@@ -580,7 +588,6 @@ describe('tool additionalContexts buffering across a step', () => {
|
||||
.map(b => (b.type === 'text' ? b.text : ''))
|
||||
expect(ctxTexts).toEqual(['ctx-c1', 'ctx-c2'])
|
||||
const contextEvents = events(agent).filter(e => e.type === 'context/message')
|
||||
expect(contextEvents.map(e => e.type === 'context/message' && e.data.envelope)).toEqual(['raw', 'raw'])
|
||||
expect(contextEvents.map(e => e.type === 'context/message' && e.data.meta)).toEqual([{ callId: 'c1' }, { callId: 'c2' }])
|
||||
})
|
||||
|
||||
@@ -591,7 +598,7 @@ describe('tool additionalContexts buffering across a step', () => {
|
||||
name: 'composite', description: 'composite', parameters: {},
|
||||
async execute(_args, exec) {
|
||||
exec.deferContext({ content: [{ type: 'text', text: 'nested-a' }], source: { kind: 'plugin', plugin: 'a' }, meta: { order: 1 } })
|
||||
exec.deferContext({ content: [{ type: 'text', text: 'nested-b' }], source: { kind: 'plugin', plugin: 'b' }, envelope: 'raw', meta: { order: 2 } })
|
||||
exec.deferContext({ content: [{ type: 'text', text: 'nested-b' }], source: { kind: 'plugin', plugin: 'b' }, meta: { order: 2 } })
|
||||
return [{ type: 'text', text: 'outer result' }]
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -354,14 +354,24 @@ describe('agent loop', () => {
|
||||
expect(flat).toContain('change of plans')
|
||||
})
|
||||
|
||||
it('steering while idle behaves like send (starts a turn)', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
it('same-tick idle steering inherits one-send-one-turn FIFO behavior', async () => {
|
||||
const adapter = new MockAdapter([textResponse('first'), textResponse('second')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
agent.steer([{ type: 'text', text: 'hello' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(agent.session.events.some(e => e.type === 'user/message')).toBe(true)
|
||||
const idle = waitForIdle(ctx, agent)
|
||||
agent.steer([{ type: 'text', text: 'first idle steer' }])
|
||||
agent.steer([{ type: 'text', text: 'second idle steer' }])
|
||||
await idle
|
||||
|
||||
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(2)
|
||||
expect(agent.session.events
|
||||
.filter(event => event.type === 'user/message')
|
||||
.map(event => event.data.content)).toEqual([
|
||||
[{ type: 'text', text: 'first idle steer' }],
|
||||
[{ type: 'text', text: 'second idle steer' }],
|
||||
])
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('inject() while idle wraps context in a one-shot turn, visible to the next request', async () => {
|
||||
@@ -385,10 +395,10 @@ describe('agent loop', () => {
|
||||
await waitForIdle(ctx, agent)
|
||||
const flat = JSON.stringify(adapter.requests[0]!.messages)
|
||||
expect(flat).toContain('file changed: a.ts')
|
||||
expect(flat).toContain('<context source=\\"plugin\\">')
|
||||
expect(flat).not.toContain('<context source=')
|
||||
})
|
||||
|
||||
it('inject() can persist raw structured context without the generic context envelope', async () => {
|
||||
it('inject() persists structured context content verbatim with durable hidden meta', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('raw-context'), { provider: 'mock', model: 'mock' })
|
||||
@@ -401,14 +411,13 @@ describe('agent loop', () => {
|
||||
|
||||
agent.inject([{ type: 'text', text }], {
|
||||
source: { kind: 'plugin', plugin: 'workspace-context' },
|
||||
envelope: 'raw',
|
||||
meta,
|
||||
})
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const contextEvent = agent.session.events.find(event => event.type === 'context/message')
|
||||
expect(contextEvent?.type === 'context/message' && contextEvent.data).toMatchObject({ envelope: 'raw', meta })
|
||||
expect(contextEvent?.type === 'context/message' && contextEvent.data).toMatchObject({ meta })
|
||||
const requestText = JSON.stringify(adapter.requests[0]!.messages)
|
||||
expect(requestText).toContain('Additional instructions from: pkg/AGENTS.md')
|
||||
expect(requestText).not.toContain('<context source=')
|
||||
@@ -432,7 +441,6 @@ describe('agent loop', () => {
|
||||
const first = { type: 'text' as const, text: 'mid-turn notice' }
|
||||
agent.inject([first], {
|
||||
source: { kind: 'plugin', plugin: 'x' },
|
||||
envelope: 'raw',
|
||||
meta,
|
||||
})
|
||||
first.text = 'mutated after inject'
|
||||
@@ -458,7 +466,6 @@ describe('agent loop', () => {
|
||||
expect(contexts).toHaveLength(2)
|
||||
expect(result.seq).toBeLessThan(contexts[0]!.seq)
|
||||
expect(contexts[0]?.type === 'context/message' && contexts[0].data).toMatchObject({
|
||||
envelope: 'raw',
|
||||
meta,
|
||||
})
|
||||
expect(contexts.flatMap(event => event.type === 'context/message' ? event.data.content : []))
|
||||
@@ -925,7 +932,149 @@ describe('agent loop', () => {
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind).toBe('completed')
|
||||
})
|
||||
|
||||
it('chains queued messages into consecutive turns', async () => {
|
||||
it('keeps same-tick sends in separate turns and checkpoints before the next starts', async () => {
|
||||
const adapter = new MockAdapter([textResponse('first answer'), textResponse('second answer')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
const firstFlush = Promise.withResolvers<undefined>()
|
||||
const releaseFirstFlush = Promise.withResolvers<undefined>()
|
||||
let flushes = 0
|
||||
ctx.on('session/flush', async (session) => {
|
||||
if (session !== agent.session) return
|
||||
flushes += 1
|
||||
if (flushes === 1) {
|
||||
firstFlush.resolve(undefined)
|
||||
await releaseFirstFlush.promise
|
||||
}
|
||||
})
|
||||
|
||||
const turns: number[] = []
|
||||
ctx.on('session/event', (session, event) => {
|
||||
if (session === agent.session && event.type === 'turn/start') turns.push(event.data.turn)
|
||||
})
|
||||
|
||||
const idle = waitForIdle(ctx, agent)
|
||||
send(agent, 'first message')
|
||||
send(agent, 'second message')
|
||||
|
||||
await firstFlush.promise
|
||||
expect(turns).toEqual([1])
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
|
||||
releaseFirstFlush.resolve(undefined)
|
||||
await idle
|
||||
|
||||
expect(turns).toEqual([1, 2])
|
||||
expect(flushes).toBe(2)
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('first answer')
|
||||
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('second message')
|
||||
})
|
||||
|
||||
it('holds a turn-end listener send behind the closing turn checkpoint', async () => {
|
||||
const adapter = new MockAdapter([textResponse('first answer'), textResponse('second answer')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
const firstFlush = Promise.withResolvers<undefined>()
|
||||
const releaseFirstFlush = Promise.withResolvers<undefined>()
|
||||
let flushes = 0
|
||||
ctx.on('session/flush', async (session) => {
|
||||
if (session !== agent.session) return
|
||||
flushes += 1
|
||||
if (flushes === 1) {
|
||||
firstFlush.resolve(undefined)
|
||||
await releaseFirstFlush.promise
|
||||
}
|
||||
})
|
||||
|
||||
const turns: number[] = []
|
||||
const statuses: string[] = []
|
||||
ctx.on('agent/status', (subject, status) => {
|
||||
if (subject === agent) statuses.push(status)
|
||||
})
|
||||
ctx.on('session/event', (session, event) => {
|
||||
if (session !== agent.session) return
|
||||
if (event.type === 'turn/start') turns.push(event.data.turn)
|
||||
if (event.type === 'turn/end' && event.data.turn === 1) send(agent, 'turn-end listener message')
|
||||
})
|
||||
|
||||
const idle = waitForIdle(ctx, agent)
|
||||
send(agent, 'first message')
|
||||
await firstFlush.promise
|
||||
|
||||
expect(turns).toEqual([1])
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
|
||||
releaseFirstFlush.resolve(undefined)
|
||||
await idle
|
||||
|
||||
expect(turns).toEqual([1, 2])
|
||||
expect(statuses).toEqual(['running', 'idle'])
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('first answer')
|
||||
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('turn-end listener message')
|
||||
})
|
||||
|
||||
it('keeps a reentrant agent/queued send as the next independent turn', async () => {
|
||||
const adapter = new MockAdapter([textResponse('first'), textResponse('second')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
let nested = false
|
||||
ctx.on('agent/queued', (subject) => {
|
||||
if (subject !== agent || nested) return
|
||||
nested = true
|
||||
send(agent, 'queued listener message')
|
||||
})
|
||||
|
||||
const idle = waitForIdle(ctx, agent)
|
||||
send(agent, 'outer message')
|
||||
await idle
|
||||
|
||||
const turns = agent.session.events.filter(event => event.type === 'turn/start')
|
||||
const messages = agent.session.events
|
||||
.filter(event => event.type === 'user/message')
|
||||
.map(event => event.data.content)
|
||||
expect(turns).toHaveLength(2)
|
||||
expect(messages).toEqual([
|
||||
[{ type: 'text', text: 'outer message' }],
|
||||
[{ type: 'text', text: 'queued listener message' }],
|
||||
])
|
||||
})
|
||||
|
||||
it('preserves independent turn sources across an adjacent microtask send', async () => {
|
||||
const adapter = new MockAdapter([textResponse('first'), textResponse('second')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
const idle = waitForIdle(ctx, agent)
|
||||
agent.send([{ type: 'text', text: 'user message' }])
|
||||
await Promise.resolve()
|
||||
agent.send(
|
||||
[{ type: 'text', text: 'plugin message' }],
|
||||
{ source: { kind: 'plugin', plugin: 'test' } },
|
||||
)
|
||||
await idle
|
||||
|
||||
const triggers = agent.session.events
|
||||
.filter(event => event.type === 'turn/start')
|
||||
.map(event => event.data.trigger)
|
||||
const sources = agent.session.events
|
||||
.filter(event => event.type === 'user/message')
|
||||
.map(event => event.data.source)
|
||||
expect(triggers).toEqual([
|
||||
{ kind: 'message', source: { kind: 'user' } },
|
||||
{ kind: 'message', source: { kind: 'plugin', plugin: 'test' } },
|
||||
])
|
||||
expect(sources).toEqual([
|
||||
{ kind: 'user' },
|
||||
{ kind: 'plugin', plugin: 'test' },
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps a session-listener send after dequeue in the following turn', async () => {
|
||||
const adapter = new MockAdapter([textResponse('first'), textResponse('second')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
@@ -948,6 +1097,37 @@ describe('agent loop', () => {
|
||||
|
||||
expect(turns).toEqual([1, 2])
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('first')
|
||||
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('second message')
|
||||
})
|
||||
|
||||
it('keeps a model-adapter callback send in the following turn', async () => {
|
||||
const agentRef: { current?: Agent } = {}
|
||||
const adapter = new MockAdapter([
|
||||
() => {
|
||||
const agent = agentRef.current
|
||||
if (agent === undefined) throw new Error('model callback ran before agent setup')
|
||||
send(agent, 'model callback message')
|
||||
return textResponse('first')
|
||||
},
|
||||
textResponse('second'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agentRef.current = agent
|
||||
|
||||
const idle = waitForIdle(ctx, agent)
|
||||
send(agent, 'outer message')
|
||||
await idle
|
||||
|
||||
const messages = agent.session.events
|
||||
.filter(event => event.type === 'user/message')
|
||||
.map(event => event.data.content)
|
||||
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(2)
|
||||
expect(messages).toEqual([
|
||||
[{ type: 'text', text: 'outer message' }],
|
||||
[{ type: 'text', text: 'model callback message' }],
|
||||
])
|
||||
})
|
||||
|
||||
it('awaits session/flush at turn end (persistence checkpoint)', async () => {
|
||||
|
||||
@@ -81,6 +81,21 @@ function turnNumbers(agent: Agent): number[] {
|
||||
.map(e => (e.data as { turn: number }).turn)
|
||||
}
|
||||
|
||||
function turnEndNumbers(agent: Agent): number[] {
|
||||
return agent.session.events
|
||||
.filter(e => e.type === 'turn/end')
|
||||
.map(e => (e.data as { turn: number }).turn)
|
||||
}
|
||||
|
||||
function userMessageCountsByTurn(agent: Agent): number[] {
|
||||
const counts: number[] = []
|
||||
for (const event of agent.session.events) {
|
||||
if (event.type === 'turn/start') counts.push(0)
|
||||
if (event.type === 'user/message') counts[counts.length - 1]! += 1
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
/** Assert a status trace is a legal run: idle/running alternating, ending idle. */
|
||||
function assertLegalStatusTrace(trace: string[]): void {
|
||||
for (let i = 1; i < trace.length; i++) {
|
||||
@@ -90,7 +105,7 @@ function assertLegalStatusTrace(trace: string[]): void {
|
||||
}
|
||||
|
||||
describe('agent loop scheduling properties', () => {
|
||||
it('a synchronous burst loses no message and uses strictly increasing turns', async () => {
|
||||
it('a synchronous burst gives every message its own strictly increasing turn', async () => {
|
||||
await fc.assert(fc.asyncProperty(
|
||||
fc.array(fc.string({ minLength: 1 }), { minLength: 1, maxLength: 6 }),
|
||||
async (texts) => {
|
||||
@@ -105,8 +120,11 @@ describe('agent loop scheduling properties', () => {
|
||||
|
||||
// No message lost: every send appears as a user/message, in order.
|
||||
expect(userMessageTexts(agent)).toEqual(texts)
|
||||
// A synchronous burst batches into exactly one turn.
|
||||
expect(turnNumbers(agent)).toEqual([1])
|
||||
// This failure-free fixture maps every item to an independent turn.
|
||||
expect(turnNumbers(agent)).toEqual(texts.map((_, i) => i + 1))
|
||||
expect(turnEndNumbers(agent)).toEqual(texts.map((_, i) => i + 1))
|
||||
expect(userMessageCountsByTurn(agent)).toEqual(texts.map(() => 1))
|
||||
expect(trace).toEqual(['running', 'idle'])
|
||||
assertLegalStatusTrace(trace)
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
@@ -137,9 +155,9 @@ describe('agent loop scheduling properties', () => {
|
||||
), { numRuns: 20, timeout: 2000 })
|
||||
})
|
||||
|
||||
it('mixed schedule (send, optionally settle) loses no message and orders turns', async () => {
|
||||
// Each step is a (text, settle?) pair: settle=true awaits idle before the
|
||||
// next send (own turn); settle=false sends in the same tick (batches).
|
||||
it('mixed settled and same-tick sends preserve one turn per message', async () => {
|
||||
// Each step optionally waits for idle before the next send; that scheduling
|
||||
// choice must not change the ordinary message-to-turn mapping.
|
||||
const stepArb = fc.record({ text: fc.string({ minLength: 1 }), settle: fc.boolean() })
|
||||
await fc.assert(fc.asyncProperty(
|
||||
fc.array(stepArb, { minLength: 1, maxLength: 6 }),
|
||||
@@ -158,14 +176,13 @@ describe('agent loop scheduling properties', () => {
|
||||
}
|
||||
await lastIdle
|
||||
|
||||
// No message lost or reordered, regardless of batching.
|
||||
// No message is lost or reordered, regardless of driver timing.
|
||||
expect(userMessageTexts(agent)).toEqual(steps.map(s => s.text))
|
||||
// Turn numbers are a strictly increasing 1..N prefix (N = turn count).
|
||||
// Every item forms one FIFO-ordered turn containing only that message.
|
||||
const turns = turnNumbers(agent)
|
||||
expect(turns).toEqual(turns.map((_, i) => i + 1))
|
||||
// Every message landed in some turn; turns never exceed messages.
|
||||
expect(turns.length).toBeLessThanOrEqual(steps.length)
|
||||
expect(turns.length).toBeGreaterThanOrEqual(1)
|
||||
expect(turns).toEqual(steps.map((_, i) => i + 1))
|
||||
expect(turnEndNumbers(agent)).toEqual(turns)
|
||||
expect(userMessageCountsByTurn(agent)).toEqual(steps.map(() => 1))
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ The lifecycle edges have two important local caveats. `agent/created` runs after
|
||||
|
||||
Most interception points are cooperative waterfalls returning seam-specific decisions. `agent/pre-step` and `agent/post-step` are serial checkpoints around a step's durable work, while `agent/request-error` is the failed-model-request recovery waterfall: a retry opens a new numbered step after the failed step closes. `agent/turn-stop` is the terminal serial fold: it runs after ordinary continuation and steering folding, and a returned stop remains in force through turn close and flush so later steering cannot create an extra step or turn. Ordinary queued prompts remain intact. The full rationale for scoped dispatch and terminal settlement is in the [agent-scope runtime-design Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way).
|
||||
|
||||
`PromptDecision.additionalContexts` is an array so every injected context keeps its own source, envelope, and metadata. A `ContinuationDecision` reason is narrower: it becomes a `steering/message`, not a `context/message`, and therefore carries only content and source.
|
||||
`PromptDecision.additionalContexts` is an array so every injected context keeps its own source and metadata. A `ContinuationDecision` reason is narrower: it becomes a `steering/message`, not a `context/message`, and therefore carries only content and source.
|
||||
|
||||
Turn and step boundaries and the model token stream are durable `session/event` facts rather than mirrored `agent/*` notifications. Consumers read `turn/*`, `step/*`, and `assistant/chunk` from the session feed; tool policy and outcome observation belong to the complete pipeline documented by [`dsh-tools`](../tools/README.md).
|
||||
|
||||
@@ -56,13 +56,15 @@ Turn and step boundaries and the model token stream are durable `session/event`
|
||||
|
||||
The handle every plugin programs against:
|
||||
|
||||
- `agent.send(content, options?)` — queue a message; starts a turn when idle. Content and resolved source become one detached, deeply frozen lossless-JSON record before `agent/queued` and enqueue; invalid data throws synchronously, and caller or notification-listener in-place mutation cannot change the log or model input (`agent/prompt-submit` still rewrites by returning replacement content).
|
||||
- `agent.steer(content, options?)` — steer a running turn (inject between steps); uses the same owned acceptance boundary and behaves like `send` when idle
|
||||
- `agent.inject(content, options?)` — accept detached in-session context without running the model; the next request sees its `context/message`. `options.envelope` defaults to the canonical `<context>` framing and may be `'raw'` when the caller owns a complete familiar frame; `options.meta` persists opaque JSON state without rendering it. While a turn is open it joins that turn, deferring FIFO while the current tool batch executes and draining before turn close if execution is interrupted; while idle it is wrapped in a one-shot `injection` turn and durability checkpoint ([the turn-enclosure invariant](../../../.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)).
|
||||
- `agent.send(content, options?)` — queue one independent FIFO item. If claimed, that item becomes the sole ordinary message in its turn; a claimed FIFO successor waits for that turn's checkpoint to settle. Broad cancellation, disposal, or a pre-start failure may instead drop it without a turn. Content and resolved source become one detached, deeply frozen lossless-JSON record before `agent/queued` and enqueue; invalid data throws synchronously, and caller or notification-listener in-place mutation cannot change the log or model input (`agent/prompt-submit` still rewrites by returning replacement content). The [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md) owns the rationale.
|
||||
- `agent.steer(content, options?)` — submit steering while the agent is `running`. An open turn records it at the next steering checkpoint before a request or continuation decision; policy can still stop before another step. After turn close and its checkpoint, remaining steering becomes later queued input unless terminal turn policy, cancellation, or disposal discards it. The method uses the same synchronous snapshot-and-validation boundary as `send` and delegates to `send` when idle
|
||||
- `agent.inject(content, options?)` — accept detached in-session context without running the model; the next request sees its `context/message` with `content` rendered verbatim as a user-role message. `options.meta` persists opaque JSON state without rendering it. While a turn is open it joins that turn, deferring FIFO while the current tool batch executes and draining before turn close if execution is interrupted; while idle it is wrapped in a one-shot `injection` turn and durability checkpoint ([the turn-enclosure invariant](../../../.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)).
|
||||
- `agent.cancel(reason?)` — cancel ALL pending work: clears the queued + steering FIFOs, aborts the in-flight step, and drops a turn about to start (the pre-step window) so a queued-but-not-started prompt never runs. A UI/ACP `session/cancel` maps to this. The single public stop primitive. Idle with nothing pending → a safe no-op.
|
||||
- `agent.whenIdle()` — resolve once the agent reaches quiescence after settling out of `running` (idle → immediately; disposed → awaits the loop exit). A non-owner's quiescence-observation hook: it observes the work settling WITHOUT tearing the agent down. Teardown is separate — a lifecycle owner stops and unregisters via `AgentHandle.dispose()`, which awaits the loop exit directly.
|
||||
- `agent.session`, `agent.status`, `agent.options`, `agent.id`
|
||||
|
||||
`running` describes a driver-wide drain interval, not proof that a turn is still open; it can cover turn close, the durability checkpoint, and consecutive queued turns.
|
||||
|
||||
### Extension points
|
||||
|
||||
- Agent creation: `AgentLoop.create()` is the concrete config-path implementation (in `dsh-agent-loop`), while programmatic consumers create/resume owned agents through `ctx.agents.create()` / `ctx.agents.resume()`. Replace the loop by implementing `Agent` and registering via `ctx.agents.register()`.
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import type { Context } from 'cordis'
|
||||
import type { Scoped } from '@deepseek-ai/dsh-scope'
|
||||
import type { ContentBlock, LlmCallConfig, Message, MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContextEnvelope, JsonValue, Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { JsonValue, Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
declare module '@deepseek-ai/dsh-system-prompt' {
|
||||
interface AssembleContext {
|
||||
@@ -32,17 +32,15 @@ export interface SendOptions {
|
||||
|
||||
/** Options specific to durable synthetic context injection. */
|
||||
export interface InjectOptions extends SendOptions {
|
||||
/** Keep the canonical context tag, or send caller-owned framing verbatim. */
|
||||
envelope?: ContextEnvelope
|
||||
/** Opaque JSON state retained in the session event but hidden from the model. */
|
||||
meta?: JsonValue
|
||||
}
|
||||
|
||||
/**
|
||||
* An agent's lifecycle state, emitted on every transition as `agent/status`:
|
||||
* `idle` (parked, waiting for queued work), `running` (a turn is in progress),
|
||||
* `disposed` (terminal — no transition leaves it, and `send`/`steer`/`inject`
|
||||
* throw).
|
||||
* `idle` (parked, waiting for queued work), `running` (the driver is draining
|
||||
* work and may be closing or checkpointing a turn), `disposed` (terminal — no
|
||||
* transition leaves it, and `send`/`steer`/`inject` throw).
|
||||
*/
|
||||
export type AgentStatus = 'idle' | 'running' | 'disposed'
|
||||
|
||||
@@ -50,16 +48,15 @@ export type AgentStatus = 'idle' | 'running' | 'disposed'
|
||||
export interface HookContext {
|
||||
content: ContentBlock[]
|
||||
source: MessageSource
|
||||
/** Keep the canonical context tag, or use caller-owned framing verbatim. */
|
||||
envelope?: ContextEnvelope
|
||||
/** Opaque JSON state retained in the session event but hidden from the model. */
|
||||
meta?: JsonValue
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt interception result. `allow.content` replaces the prompt and each
|
||||
* `additionalContexts` entry becomes a separate context message. `block` records a
|
||||
* durable `prompt/blocked`; an all-blocked batch ends a zero-step rejected turn.
|
||||
* `additionalContexts` entry becomes a separate context message. `block`
|
||||
* records a durable `prompt/blocked` and ends the claimed prompt's zero-step
|
||||
* turn as rejected.
|
||||
*/
|
||||
export type PromptDecision =
|
||||
| { kind: 'allow'; content?: ContentBlock[]; additionalContexts?: HookContext[] }
|
||||
@@ -97,15 +94,20 @@ export interface Agent {
|
||||
readonly ctx: Context
|
||||
|
||||
/**
|
||||
* Queue detached, frozen lossless-JSON input; starts a turn when idle.
|
||||
* Queue one detached, frozen lossless-JSON item. If claimed, it is the sole
|
||||
* ordinary message in its FIFO-ordered turn; the next claimed item waits for
|
||||
* that turn's checkpoint.
|
||||
* Invalid input throws synchronously before notification or enqueue.
|
||||
*/
|
||||
send(content: ContentBlock[], options?: SendOptions): void
|
||||
|
||||
/**
|
||||
* Steer a running turn: content is injected between steps of the current
|
||||
* turn. Uses the same owned-value and synchronous-validation boundary as
|
||||
* {@link send}; when idle, behaves exactly like that method.
|
||||
* Submit steering while the agent is `running`. An open turn records it at
|
||||
* the next steering checkpoint before a request or continuation decision;
|
||||
* policy may stop before another step. After turn close and its checkpoint,
|
||||
* any remainder is queued for a later turn; terminal `agent/turn-stop`,
|
||||
* cancellation, or disposal may discard it. Uses the same synchronous
|
||||
* snapshot-and-validation boundary as {@link send}; when idle, delegates to it.
|
||||
*/
|
||||
steer(content: ContentBlock[], options?: SendOptions): void
|
||||
|
||||
@@ -119,10 +121,11 @@ export interface Agent {
|
||||
inject(content: ContentBlock[], options?: InjectOptions): void
|
||||
|
||||
/**
|
||||
* Clear queued and steering work, including work waiting to start, and abort
|
||||
* the active step. The supplied reason is preserved across pre-step and active
|
||||
* cancellation windows, and `whenIdle()` resolves after cancellation reaches
|
||||
* quiescence. Idle cancellation is a no-op and does not arm a later cancel.
|
||||
* Clear all queued and steering work, including items waiting to start, and
|
||||
* abort the active step. The supplied reason is preserved across pre-step
|
||||
* and active cancellation windows, and `whenIdle()` resolves after
|
||||
* cancellation reaches quiescence. Idle cancellation is a no-op and does not
|
||||
* arm a later cancel.
|
||||
*/
|
||||
cancel(reason?: string): void
|
||||
|
||||
@@ -203,10 +206,10 @@ declare module 'cordis' {
|
||||
*/
|
||||
'agent/pre-step'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, signal: AbortSignal): Promise<void> | void
|
||||
/**
|
||||
* Allow, rewrite, or block one drained prompt before it becomes a user
|
||||
* Allow, rewrite, or block one claimed prompt before it becomes a user
|
||||
* message. Call `next()` for the unchanged default.
|
||||
* @param agent - the agent draining its inbox.
|
||||
* @param content - the drained message's blocks, as queued.
|
||||
* @param agent - the agent whose turn claimed the message.
|
||||
* @param content - the claimed message's blocks, as queued.
|
||||
* @param source - the message's resolved source.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
* @mode waterfall
|
||||
|
||||
@@ -34,7 +34,7 @@ The store pairs announced creation with disposal, publishes post-commit append n
|
||||
|
||||
Plain class (not a Cordis Service). Create via `ctx.sessions.create()`.
|
||||
|
||||
- `session.append(type, data, opts?)` snapshots and freezes durable data and surface metadata, validates marker shape, provenance, and complete replacement coverage, commits synchronously, then notifies observers with independent failure containment. Reentrant attached-session appends reject, and runtime checks cover widened unions and loaded logs.
|
||||
- `session.append(type, data, opts?)` snapshots and freezes durable data and surface metadata, validates marker shape, provenance, complete replacement coverage, and content-only single-result `tool/result` rewrites, commits synchronously, then notifies observers with independent failure containment. Reentrant attached-session appends reject, and runtime checks cover widened unions and loaded logs.
|
||||
- `session.deriveMessages()` incrementally projects each new surface entry once and returns a fresh array over shared frozen messages. Assistant projections preserve provider/model provenance and adapter-private replay state. A surface rewrite rebuilds the projection; there is no raw-log fallback.
|
||||
- `session.deriveEventMessage(event)` is the canonical per-event projection used by reconstruction and request checks.
|
||||
- `session.surface` exposes the readonly `SessionSurface` view owned by the session's single incremental surface manager; `replaceGeneration` changes on every committed rewrite.
|
||||
@@ -51,14 +51,14 @@ Durable values need one accepted representation, not a check followed by a secon
|
||||
- `SurfaceOp` — how an event entered the ordered surface: `'append'` (normal tail append) or `{ op: 'replace', start, end }` (replace entries from `start` through `end` inclusive — both must be valid surface seqs; `start === end` replaces one entry). Used by compaction to shadow old events without deleting them.
|
||||
- `SurfaceIntent` — `{ surfaceOp: SurfaceOp; sourceEventSeqs?: number[] }`, the required third parameter to `session.append()` for surface-eligible types.
|
||||
- `SessionSurface` — the readonly live `nodes` and `replaceGeneration` projection exposed by `session.surface`; candidate validation remains private to `Session`.
|
||||
- `foldSurface(events)` — replay the canonical surface contract into detached current event sequences and actual replacement ranges. The same pass rejects non-contiguous seqs, misplaced or malformed metadata, empty or duplicate provenance, non-earlier sources, invalid positional ranges, and replacements that fail to cite every shadowed surface entry; `SurfaceManager` shares the atomic transition while retaining only its incremental sequence cache.
|
||||
- `foldSurface(events)` — replay the canonical surface contract into detached current event sequences and actual replacement ranges. The same pass rejects non-contiguous seqs, misplaced or malformed metadata, empty or duplicate provenance, non-earlier sources, invalid positional ranges, replacements that fail to cite every shadowed surface entry, and a `tool/result` replacement that changes anything except one current result's `content`; `SurfaceManager` shares the atomic transition while retaining only its incremental sequence cache.
|
||||
- `isSurfaceEvent(event)` / `isSurfaceEligibleType(type)` — the first narrows a `SessionEvent` to a fully formed surface event; the second detects a surface-eligible event missing its marker when validating a seed or loaded log.
|
||||
|
||||
### Request-header reconstruction (`request-header.ts`)
|
||||
|
||||
`request/header` records a full canonical snapshot of the non-history request envelope with reason `initial`, `resume`, or `change`. `foldRequestHeader()` selects the latest snapshot; legacy delta events and the removed `fallback` reason are rejected. `messagePrefix` remains separate from derived history. See the [reconstructable-requests Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md).
|
||||
|
||||
`context/message` defaults to the canonical tagged context projection. A producer may set `envelope: 'raw'` when its `content` already contains the complete model-facing frame, and may attach JSON `meta` for replayable plugin state; metadata remains durable but is excluded from `deriveMessages()`.
|
||||
`context/message` renders its `content` verbatim as a user-role message, and may attach JSON `meta` for replayable plugin state; metadata remains durable but is excluded from `deriveMessages()`.
|
||||
|
||||
### Session event vocabulary (`types.ts`)
|
||||
|
||||
@@ -81,7 +81,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: `create(id, { seed })` validates and freezes a contiguous current-format log and rebuilds its surface; request headers require provider/model and assistant messages require provider/model provenance. `fork(source, boundary?, childSessionId?)` selects a completed-turn prefix and records lineage.
|
||||
- Compaction: the `dsh-compact-basic` plugin appends a `user/message` with `surfaceOp: { op: 'replace', start, end }` to shadow old surface entries behind a summary checkpoint. Tool-pairing boundary policy and its cache belong to the [`dsh-compact` seam](../../compact/compact/README.md), while this package owns ordered surface membership and `replaceGeneration`.
|
||||
- Compaction: `dsh-compact-basic` appends a `user/message` replacement for summary checkpoints, while `dsh-compact-tool-result-prune` appends a content-only `tool/result` replacement. Tool-pairing boundary policy and its cache belong to the [`dsh-compact` seam](../../compact/compact/README.md), while this package owns ordered surface membership, replacement validation, and `replaceGeneration`.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -89,7 +89,7 @@ Every `SessionEvent` carries two optional top-level fields (structural metadata)
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The model receives projections of `user/message`, `assistant/message`, and `tool/result` surface entries verbatim. A `context/message` is a user-role message containing exactly `<context source="<source-kind>">`, its content blocks, and `</context>`; `steering/message` uses the identical `<steering source="<source-kind>">` / `</steering>` wrapper. Tool calls live inside assistant messages. Chunks, boundaries, usage, hook records, todo records, and other log-only events add no message.
|
||||
The model receives projections of `user/message`, `assistant/message`, `tool/result`, `context/message`, and `steering/message` surface entries verbatim: each is a user- or assistant-role message carrying its content blocks unchanged. Tool calls live inside assistant messages. Chunks, boundaries, usage, hook records, todo records, and other log-only events add no message.
|
||||
|
||||
#### Token effect
|
||||
|
||||
|
||||
@@ -11,9 +11,9 @@ import { isAbsolute } from 'node:path'
|
||||
import { deepFreeze } from '@deepseek-ai/dsh-llm'
|
||||
import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||
import type { Scoped } from '@deepseek-ai/dsh-scope'
|
||||
import type { ContentBlock, Message, MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import type { Message } from '@deepseek-ai/dsh-llm'
|
||||
import { SESSION_FORMAT_VERSION, SessionId } from './types.ts'
|
||||
import type { ContextEnvelope, CreateSessionOptions, EpochHeader, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType } from './types.ts'
|
||||
import type { CreateSessionOptions, EpochHeader, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType } from './types.ts'
|
||||
import { snapshotJsonValue } from './json.ts'
|
||||
import { SurfaceManager } from './surface.ts'
|
||||
import type { SessionSurface } from './surface.ts'
|
||||
@@ -80,21 +80,6 @@ declare module 'cordis' {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render injected context as tagged synthetic user-role content, keeping the
|
||||
* canonical session vocabulary provider-neutral. Adapter-specific exceptions
|
||||
* belong in the adapter.
|
||||
*/
|
||||
function renderTagged(tag: string, content: ContentBlock[], source: MessageSource): ContentBlock[] {
|
||||
const open = `<${tag} source=${JSON.stringify(source.kind)}>`
|
||||
const close = `</${tag}>`
|
||||
return [
|
||||
{ type: 'text', text: open },
|
||||
...content,
|
||||
{ type: 'text', text: close },
|
||||
]
|
||||
}
|
||||
|
||||
/** Detach, validate, and freeze the creation metadata published by a session. */
|
||||
function snapshotSessionHeader(id: SessionId, source?: SessionHeader): SessionHeader {
|
||||
const input: unknown = source === undefined
|
||||
@@ -228,22 +213,6 @@ interface SessionEntry {
|
||||
/** Store attachment for the append path; module-private to keep Session store-agnostic publicly. */
|
||||
const attachments = new WeakMap<Session, SessionEntry>()
|
||||
|
||||
/**
|
||||
* Render one context contribution exactly as it will appear in model history.
|
||||
* @param content - content blocks supplied by the context producer.
|
||||
* @param source - attribution used by the canonical context envelope.
|
||||
* @param envelope - canonical tagged framing or caller-owned raw framing.
|
||||
* @returns a detached block list ready for the derived model transcript.
|
||||
*/
|
||||
export function renderContextContent(
|
||||
content: ContentBlock[],
|
||||
source: MessageSource,
|
||||
envelope: ContextEnvelope = 'context',
|
||||
): ContentBlock[] {
|
||||
const cloned = structuredClone(content)
|
||||
return envelope === 'raw' ? cloned : renderTagged('context', cloned, source)
|
||||
}
|
||||
|
||||
/**
|
||||
* An event-sourced session: an append-only log of {@link SessionEvent}s.
|
||||
*
|
||||
@@ -509,7 +478,18 @@ export class Session {
|
||||
// trace/replay data.
|
||||
|
||||
switch (event.type) {
|
||||
case 'user/message': {
|
||||
// Injected context and mid-turn steering project identically to a user
|
||||
// prompt: content verbatim, in user role. context's `source`/`meta` and
|
||||
// steering's `turn` are log-only and do not reach the model. Do NOT
|
||||
// re-add per-type framing (e.g. `<context>`/`<steering>`) here: framing is
|
||||
// caller-owned — a producer bakes it into `content`, as workspace-context
|
||||
// does with `<system-reminder>` — or, if reintroduced, must be driven by
|
||||
// the event `meta` map and a dedicated renderer, keeping this projection a
|
||||
// verbatim pass-through. See the deferred design note in
|
||||
// ../../../../.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md
|
||||
case 'user/message':
|
||||
case 'context/message':
|
||||
case 'steering/message': {
|
||||
return { role: 'user', content: event.data.content }
|
||||
}
|
||||
case 'assistant/message': {
|
||||
@@ -526,14 +506,6 @@ export class Session {
|
||||
content: [{ type: 'tool-result', toolCallId: callId, content, isError }],
|
||||
}
|
||||
}
|
||||
case 'context/message': {
|
||||
const { content, source, envelope } = event.data
|
||||
return { role: 'user', content: renderContextContent(content, source, envelope) }
|
||||
}
|
||||
case 'steering/message': {
|
||||
const { content, source } = event.data
|
||||
return { role: 'user', content: renderTagged('steering', content, source) }
|
||||
}
|
||||
default:
|
||||
// A non-surface event (boundary, chunk, log-only record) projects to
|
||||
// no message. Merge-extensible union: no assertNever here.
|
||||
|
||||
@@ -124,6 +124,14 @@ function validateEvent(
|
||||
break
|
||||
}
|
||||
case 'tool/result': {
|
||||
// Session has already validated a provenance-backed content rewrite.
|
||||
// It is durable turn work, not a second execution of the original call.
|
||||
if (event.surfaceOp !== 'append') {
|
||||
if (trace.openTurn === null) {
|
||||
fail('tool/result surface replacement appended outside any open turn')
|
||||
}
|
||||
break
|
||||
}
|
||||
requireOpenStep(trace, 'tool/result', event.data.turn, event.data.step, fail)
|
||||
const syntheticInterrupted = event.data.isError && event.data.error?.code === 'interrupted'
|
||||
if (!trace.pendingCalls.has(event.data.callId) && !syntheticInterrupted) {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* @module @deepseek-ai/dsh-session/surface
|
||||
*/
|
||||
|
||||
import { isDeepStrictEqual } from 'node:util'
|
||||
import type { SessionEvent, SurfaceEvent, SurfaceEventType, SurfaceOp } from './types.ts'
|
||||
|
||||
/** Runtime counterpart of the message-producing event union. */
|
||||
@@ -187,11 +188,37 @@ function replacementRange(
|
||||
}
|
||||
}
|
||||
|
||||
/** Restrict a tool-result replacement to one current result's content. */
|
||||
function assertToolResultRewrite(
|
||||
event: SessionEvent,
|
||||
shadowedSeqs: readonly number[],
|
||||
events: readonly SessionEvent[],
|
||||
): void {
|
||||
if (event.type !== 'tool/result') return
|
||||
if (shadowedSeqs.length !== 1) {
|
||||
throw new Error('tool/result surface replacement must rewrite exactly one current node')
|
||||
}
|
||||
for (const originalSeq of shadowedSeqs) {
|
||||
const original = events[originalSeq]
|
||||
if (original?.type !== 'tool/result') {
|
||||
throw new Error('tool/result surface replacement must target a current tool/result')
|
||||
}
|
||||
const originalRest = { ...original.data } as Record<string, unknown>
|
||||
const replacementRest = { ...event.data } as Record<string, unknown>
|
||||
delete originalRest['content']
|
||||
delete replacementRest['content']
|
||||
if (!isDeepStrictEqual(originalRest, replacementRest)) {
|
||||
throw new Error('tool/result surface replacement may change only content')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate one event at its replay boundary and prepare its atomic fold transition. */
|
||||
function planSurfaceEvent(
|
||||
state: SurfaceFoldState,
|
||||
event: SessionEvent,
|
||||
expectedSeq: number,
|
||||
events: readonly SessionEvent[],
|
||||
): SurfacePlan | undefined {
|
||||
if (event.seq !== expectedSeq) {
|
||||
throw new Error(`session event seq ${event.seq} is not contiguous; expected ${expectedSeq}`)
|
||||
@@ -204,6 +231,7 @@ function planSurfaceEvent(
|
||||
}
|
||||
const range = replacementRange(state, surfaceOp)
|
||||
assertProvenance(event, range.shadowedSeqs)
|
||||
assertToolResultRewrite(event, range.shadowedSeqs, events)
|
||||
return {
|
||||
kind: 'replace',
|
||||
seq: event.seq,
|
||||
@@ -218,8 +246,9 @@ function applySurfaceEvent(
|
||||
state: SurfaceFoldState,
|
||||
event: SessionEvent,
|
||||
expectedSeq: number,
|
||||
events: readonly SessionEvent[],
|
||||
): SurfaceFoldReplacement | undefined {
|
||||
const plan = planSurfaceEvent(state, event, expectedSeq)
|
||||
const plan = planSurfaceEvent(state, event, expectedSeq, events)
|
||||
if (plan?.kind === 'append') {
|
||||
state.nodes.push(plan.seq)
|
||||
} else if (plan?.kind === 'replace') {
|
||||
@@ -239,13 +268,13 @@ function applySurfaceEvent(
|
||||
* Replay a complete session log through the canonical surface fold.
|
||||
* @param events - session events in contiguous seq order.
|
||||
* @returns detached current sequences and replacement history.
|
||||
* @throws when an event violates surface metadata, provenance, or range rules.
|
||||
* @throws when an event violates surface metadata, provenance, range, or tool-result rewrite rules.
|
||||
*/
|
||||
export function foldSurface(events: readonly SessionEvent[]): SurfaceFoldResult {
|
||||
const state = createFoldState()
|
||||
const replacements: SurfaceFoldReplacement[] = []
|
||||
for (const [index, event] of events.entries()) {
|
||||
const replacement = applySurfaceEvent(state, event, index)
|
||||
const replacement = applySurfaceEvent(state, event, index, events)
|
||||
if (replacement !== undefined) replacements.push(replacement)
|
||||
}
|
||||
return { nodes: [...state.nodes], replacements }
|
||||
@@ -266,7 +295,7 @@ export class SurfaceManager implements SessionSurface {
|
||||
*/
|
||||
validateNext(event: SessionEvent): void {
|
||||
if (this._lastProcessedSeq < this.log.length - 1) this._processDelta()
|
||||
planSurfaceEvent(this._state, event, this.log.length)
|
||||
planSurfaceEvent(this._state, event, this.log.length, this.log)
|
||||
}
|
||||
|
||||
/** Monotonic count of folded positional replacements. */
|
||||
@@ -285,7 +314,7 @@ export class SurfaceManager implements SessionSurface {
|
||||
private _processDelta(): void {
|
||||
for (let i = this._lastProcessedSeq + 1; i < this.log.length; i++) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded by the loop condition
|
||||
applySurfaceEvent(this._state, this.log[i]!, i)
|
||||
applySurfaceEvent(this._state, this.log[i]!, i, this.log)
|
||||
this._lastProcessedSeq = i
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,6 @@ import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
import type { AssistantProvenance, CallId, ContentBlock, LlmCallConfig, Message, MessageSource, StreamChunk, TokenUsage, ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
import type { JsonValue } from './json.ts'
|
||||
|
||||
/** Canonical context-tag framing, or caller-owned framing rendered verbatim. */
|
||||
export type ContextEnvelope = 'context' | 'raw'
|
||||
|
||||
/** Identifies one session in the store (and its persistence artifacts). */
|
||||
export type SessionId = Branded<'SessionId'>
|
||||
|
||||
@@ -109,8 +106,8 @@ export interface TurnEndReasonMap {
|
||||
/** At least one step reached its output-token ceiling, even if a plugin continued the turn. */
|
||||
'max-tokens': { kind: 'max-tokens' }
|
||||
/**
|
||||
* Policy blocked every prompt before the first step. The zero-step turn still
|
||||
* records a balanced durable boundary and the veto reason.
|
||||
* Policy blocked the turn's claimed prompt before the first step. The
|
||||
* zero-step turn still records a balanced durable boundary and veto reason.
|
||||
*/
|
||||
rejected: { kind: 'rejected'; reason: string }
|
||||
/**
|
||||
@@ -179,40 +176,44 @@ export type RequestHeaderReason = 'initial' | 'resume' | 'change'
|
||||
*/
|
||||
export interface SessionEventMap {
|
||||
/**
|
||||
* Opens turn `turn`. `trigger` records what started it — a drained message
|
||||
* batch or an idle-time injection. The turn is the durability/replay
|
||||
* Opens turn `turn`. `trigger` records what started it — one claimed queued
|
||||
* message or an idle-time injection. The turn is the durability/replay
|
||||
* boundary: every event sits between a `turn/start` and its matching
|
||||
* `turn/end` (the turn-enclosure invariant).
|
||||
*/
|
||||
'turn/start': { turn: number; trigger: TurnTrigger }
|
||||
/**
|
||||
* Closes turn `turn` with the {@link TurnEndReason} that ended it. The loop
|
||||
* fires the awaited `session/flush` checkpoint at every turn end, so the turn
|
||||
* boundary is also the durable-commit boundary.
|
||||
* awaits `session/flush` after an ordinary turn ends before claiming the next
|
||||
* queued item. Success commits the turn; rejection is reported live and does
|
||||
* not prevent later work.
|
||||
*/
|
||||
'turn/end': { turn: number; reason: TurnEndReason }
|
||||
/** Opens step `step` of turn `turn` — one model call plus the tool executions it requested. */
|
||||
'step/start': { turn: number; step: number }
|
||||
/** Closes step `step` of turn `turn`. */
|
||||
'step/end': { turn: number; step: number }
|
||||
/** A user-visible prompt (queued message drained at turn start). */
|
||||
/** A user-visible prompt (the queued message claimed for this turn). */
|
||||
'user/message': { content: ContentBlock[]; source: MessageSource }
|
||||
/**
|
||||
* Durable record of a prompt veto and its reason. It is log-only: the blocked
|
||||
* prompt never enters the model-visible surface, including in a mixed batch.
|
||||
* prompt never enters the model-visible surface, and its turn runs zero steps.
|
||||
*/
|
||||
'prompt/blocked': { content: ContentBlock[]; source: MessageSource; reason: string }
|
||||
/**
|
||||
* In-session context injection (file-change notices, subdir AGENTS.md,
|
||||
* skill content, cron notifications, …). Rendered into the derived history
|
||||
* as synthetic context — NOT a user prompt. `envelope: 'raw'` lets a caller
|
||||
* own the complete model-facing frame; `meta` is durable JSON state omitted
|
||||
* from the model projection.
|
||||
* as a synthetic user-role message carrying `content` verbatim — NOT a
|
||||
* user prompt. `meta` is durable JSON state omitted from the model
|
||||
* projection; it is also the intended channel for any future framing
|
||||
* directive (a producer declares the frame, a dedicated renderer applies it —
|
||||
* see the deferred note in
|
||||
* ../../../../.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md),
|
||||
* so the surface keeps projecting `content` verbatim rather than wrapping it.
|
||||
*/
|
||||
'context/message': {
|
||||
content: ContentBlock[]
|
||||
source: MessageSource
|
||||
envelope?: ContextEnvelope
|
||||
meta?: JsonValue
|
||||
}
|
||||
/** Raw stream chunk — token-level replay fidelity. */
|
||||
|
||||
@@ -180,6 +180,82 @@ describe('session-log invariants', () => {
|
||||
}, { surfaceOp: 'append' })).toThrow(/no prior tool\/call/)
|
||||
})
|
||||
|
||||
it('keeps fresh tool-result appends open-step checked', async () => {
|
||||
const { ctx } = await setup()
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
expect(() => session.append('tool/result', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
callId: CallId('closed'),
|
||||
content: [],
|
||||
isError: false,
|
||||
}, { surfaceOp: 'append' })).toThrow(/open is turn 1\/step null/)
|
||||
})
|
||||
|
||||
it('treats a validated tool-result replacement as a turn-enclosed rewrite', async () => {
|
||||
const { ctx } = await setup()
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
session.append('tool/call', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
callId: CallId('rewrite'),
|
||||
name: 'echo',
|
||||
arguments: '{}',
|
||||
})
|
||||
const original = session.append('tool/result', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
callId: CallId('rewrite'),
|
||||
content: [{ type: 'text', text: 'original' }],
|
||||
isError: false,
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('step/end', { turn: 1, step: 1 })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
|
||||
session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
expect(() => session.append('tool/result', {
|
||||
...original.data,
|
||||
content: [{ type: 'text', text: 'pruned' }],
|
||||
}, {
|
||||
surfaceOp: { op: 'replace', start: original.seq, end: original.seq },
|
||||
sourceEventSeqs: [original.seq],
|
||||
})).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects a tool-result replacement outside a turn', async () => {
|
||||
const { ctx } = await setup()
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
session.append('tool/call', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
callId: CallId('rewrite'),
|
||||
name: 'echo',
|
||||
arguments: '{}',
|
||||
})
|
||||
const original = session.append('tool/result', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
callId: CallId('rewrite'),
|
||||
content: [{ type: 'text', text: 'original' }],
|
||||
isError: false,
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('step/end', { turn: 1, step: 1 })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
|
||||
expect(() => session.append('tool/result', {
|
||||
...original.data,
|
||||
content: [{ type: 'text', text: 'pruned' }],
|
||||
}, {
|
||||
surfaceOp: { op: 'replace', start: original.seq, end: original.seq },
|
||||
sourceEventSeqs: [original.seq],
|
||||
})).toThrow(/outside any open turn/)
|
||||
})
|
||||
|
||||
it('allows interrupted repair results and unresolved calls at step end', async () => {
|
||||
const repaired = (await setup()).ctx.sessions.create()
|
||||
expect(() => {
|
||||
|
||||
@@ -48,7 +48,7 @@ describe('Session', () => {
|
||||
expect(structuredClone(turnEnd.data.reason)).toEqual({ kind: 'max-tokens' })
|
||||
})
|
||||
|
||||
it('renders context and steering messages as tagged synthetic user content', () => {
|
||||
it('renders context and steering messages as plain user content', () => {
|
||||
const session = new Session(SessionId('s2'))
|
||||
session.append('context/message', {
|
||||
content: [{ type: 'text', text: 'file changed: a.ts' }],
|
||||
@@ -62,12 +62,12 @@ describe('Session', () => {
|
||||
|
||||
const [contextMessage, steeringMessage] = session.deriveMessages()
|
||||
expect(contextMessage!.role).toBe('user')
|
||||
expect(contextMessage!.content[0]).toMatchObject({ type: 'text', text: '<context source="plugin">' })
|
||||
expect(contextMessage!.content.at(-1)).toMatchObject({ type: 'text', text: '</context>' })
|
||||
expect(steeringMessage!.content[0]).toMatchObject({ type: 'text', text: '<steering source="user">' })
|
||||
expect(contextMessage!.content).toEqual([{ type: 'text', text: 'file changed: a.ts' }])
|
||||
expect(steeringMessage!.role).toBe('user')
|
||||
expect(steeringMessage!.content).toEqual([{ type: 'text', text: 'focus on tests' }])
|
||||
})
|
||||
|
||||
it('renders raw context without a generic envelope while preserving structured metadata', () => {
|
||||
it('keeps context meta durable in the event while hiding it from the projection', () => {
|
||||
const session = new Session(SessionId('s2-raw'))
|
||||
const meta = {
|
||||
kind: 'workspace-instructions',
|
||||
@@ -77,7 +77,6 @@ describe('Session', () => {
|
||||
session.append('context/message', {
|
||||
content: [{ type: 'text', text: '<system-reminder>Additional instructions from: pkg/AGENTS.md</system-reminder>' }],
|
||||
source: { kind: 'plugin', plugin: 'workspace-context' },
|
||||
envelope: 'raw',
|
||||
meta,
|
||||
}, { surfaceOp: 'append' })
|
||||
|
||||
|
||||
@@ -369,8 +369,8 @@ describe('deriveMessages with surface', () => {
|
||||
s.append('steering/message', { turn: 1, content: [{ type: 'text', text: 'focus' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
const messages = s.deriveMessages()
|
||||
expect(messages).toHaveLength(2)
|
||||
expect(messages[0]!.content[0]).toMatchObject({ type: 'text', text: '<context source="plugin">' })
|
||||
expect(messages[1]!.content[0]).toMatchObject({ type: 'text', text: '<steering source="user">' })
|
||||
expect(messages[0]!.content).toEqual([{ type: 'text', text: 'file changed' }])
|
||||
expect(messages[1]!.content).toEqual([{ type: 'text', text: 'focus' }])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ The live registry pipeline has three transformable waterfalls followed by the ob
|
||||
- `ToolExecutionToken` — a fresh branded `Symbol` assigned by the registry. It supports equality correlation only and never crosses a model, log, or worker boundary.
|
||||
- `ToolExecution` — the pipeline-owned call: immutable `{ token, callId, name, arguments, agent?, parent? }` identity plus optional operational `signal`, which an around wrapper may add, replace, remove, and restore. A nested call's `parent` is a `ToolExecutionToken`, not an execution object.
|
||||
- `ToolRunContext` — the execution passed to a tool body, extending `ToolExecution` with `deferContext(context)`. Composite tools use it to ferry context produced by nested dispatches to the outer result even when the tool later throws; it never injects immediately.
|
||||
- `ToolExecutionResult` — losslessly JSON-serializable outcome: `{ content, isError, error?, additionalContexts?, meta? }`. Call identity stays on the immutable `ToolExecution` supplied alongside the result instead of being duplicated on the outcome. The registry materializes and freezes the complete post-policy value before final observation. On failure with a `HarnessError`, `error: { name, code }` carries the structured failure class alongside the model-facing text. `additionalContexts` preserves each deferred or post-execute `HookContext` with its own source, envelope, and durable JSON metadata; the loop buffers the array and appends each entry as a `context/message` after all `tool/result`s in the step.
|
||||
- `ToolExecutionResult` — losslessly JSON-serializable outcome: `{ content, isError, error?, additionalContexts?, meta? }`. Call identity stays on the immutable `ToolExecution` supplied alongside the result instead of being duplicated on the outcome. The registry materializes and freezes the complete post-policy value before final observation. On failure with a `HarnessError`, `error: { name, code }` carries the structured failure class alongside the model-facing text. `additionalContexts` preserves each deferred or post-execute `HookContext` with its own source and durable JSON metadata; the loop buffers the array and appends each entry as a `context/message` after all `tool/result`s in the step.
|
||||
- `PreToolDecision` — `{kind:'allow'}` | `{kind:'deny', reason}` | `{kind:'ask', reason?}`. Input rewrite is deliberately not offered; `ask` is serviced by [`ctx.approval`](../../ui/user-approval/README.md) when mounted and otherwise degrades to deny.
|
||||
- `PostToolDecision` — `{kind:'accept', content?, additionalContexts?}` (keep the call successful, optionally replacing the model-facing content) | `{kind:'block', feedback, additionalContexts?}` (turn it into an `isError` whose content is the corrective feedback). Accept preserves tool-deferred contexts before decision contexts; block discards tool-deferred contexts and exposes only contexts explicitly supplied by the blocking decision.
|
||||
- `ToolGuard` — `(execution) => string | undefined`; the returned string is a final monotonic denial reason evaluated after the reorderable pre-execute waterfall and before dispatch.
|
||||
@@ -108,7 +108,7 @@ Returning `undefined` selects generic fallback. Presenters depend only on their
|
||||
Under `code` or `both`, the registry exposes the reserved `run_code` transport and a deterministic TypeScript SDK for the current scope; only program output re-enters model context. Each JSON-normalized binding re-enters the complete tool pipeline sequentially with logged correlation to the outer call. Denials reject that binding, ordinary side effects are not rolled back, and sub-call `additionalContexts` are deferred through the parent result to preserve call/result adjacency. Run settlement aborts and drains outstanding bindings; failures surface as `CodeRunFailedError`. See the [Code Mode Agent Note](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md) and [code-runtime seam](../../code-runtime/README.md). Try `pnpm run demo:code-mode`.
|
||||
|
||||
- **The SDK section** (`tools:sdk`, order 150): a lazy prompt section regenerating, at each assembly, a `declare const tools: {...}` TypeScript declaration of the calling scope's visible end capabilities (exotic names via quoted keys), plus fixed usage instructions. Deterministic — lexicographic tool order, byte-identical text for an unchanged tool set (prefix-cache-friendly). The codegen (`jsonSchemaToTs`, exported) is total: constructs outside the `defineTool` subset degrade to `unknown`, never throw.
|
||||
- **The dispatch bridge** (`run_code`'s execute): every binding call is JSON-normalized before dispatch (a value that does not survive — `BigInt`, circulars — rejects that one call, so the dispatched form and logged form are the same JSON value by construction), serialized through a per-run queue (even `Promise.all` executes underlying calls one at a time in submission order), given the outer execution's opaque token as `parent`, and run through the complete pre-execute → guards → execute → post-execute → result pipeline. A denial reaches the program as a binding rejection, and each sub-call is logged as a `tool/code-dispatch` session event with deterministic id `<parent>:code:<n>`; `deriveMessages()` does not surface that event. Token correlation lets commit-style observers defer an inner success until the final `run_code` result without exposing the live outer execution; ordinary tool side effects are not rolled back. Every sub-call `additionalContexts` entry is deferred through the outer `ToolRunContext` in dispatch order; the loop appends those contexts only after the parent `run_code` result, preserving adjacency and retaining each source/envelope/meta even when the program later fails.
|
||||
- **The dispatch bridge** (`run_code`'s execute): every binding call is JSON-normalized before dispatch (a value that does not survive — `BigInt`, circulars — rejects that one call, so the dispatched form and logged form are the same JSON value by construction), serialized through a per-run queue (even `Promise.all` executes underlying calls one at a time in submission order), given the outer execution's opaque token as `parent`, and run through the complete pre-execute → guards → execute → post-execute → result pipeline. A denial reaches the program as a binding rejection, and each sub-call is logged as a `tool/code-dispatch` session event with deterministic id `<parent>:code:<n>`; `deriveMessages()` does not surface that event. Token correlation lets commit-style observers defer an inner success until the final `run_code` result without exposing the live outer execution; ordinary tool side effects are not rolled back. Every sub-call `additionalContexts` entry is deferred through the outer `ToolRunContext` in dispatch order; the loop appends those contexts only after the parent `run_code` result, preserving adjacency and retaining each source/meta even when the program later fails.
|
||||
- **Settlement discipline**: the bridge owns a run-scoped abort that follows the outer signal in and fires when the run settles for any reason, so a budget expiry aborts an in-flight sub-tool instead of orphaning it; the bridge then drains its queue BEFORE returning, so every `tool/code-dispatch` lands inside the open turn. A failed run throws `CodeRunFailedError` (`code: 'CODE_RUN_FAILED'`, message = the failure kind + captured logs), which the pipeline converts to a structured `isError` the model self-corrects from.
|
||||
|
||||
### Parallel execution
|
||||
|
||||
@@ -235,8 +235,8 @@ export interface ToolExecution extends ToolExecutionInput {
|
||||
export interface ToolRunContext extends ToolExecution {
|
||||
/**
|
||||
* Defer one nested-dispatch context until this tool's final result reaches
|
||||
* the agent loop. Contexts retain their individual source, envelope, and
|
||||
* metadata and are emitted in call order.
|
||||
* the agent loop. Contexts retain their individual source and metadata and
|
||||
* are emitted in call order.
|
||||
*/
|
||||
deferContext(context: HookContext): void
|
||||
}
|
||||
@@ -958,14 +958,19 @@ export class ToolRegistry extends Service {
|
||||
// Freeze the remaining mutable signal slot before observers receive the
|
||||
// shared WeakMap-keyable execution object.
|
||||
Object.freeze(exec)
|
||||
const { name: toolName, callId } = exec
|
||||
const reportFailure = (error: unknown): void => {
|
||||
this.ctx.logger.warn(`tool "${toolName}" (${callId}): tools/result observer failed: ${errorMessage(error)}`)
|
||||
}
|
||||
const callbacks = this.ctx.events.dispatch('emit', [
|
||||
scopeTarget(this, exec.agent), 'tools/result', exec, result,
|
||||
])
|
||||
for (const callback of callbacks) {
|
||||
try {
|
||||
callback(exec, result)
|
||||
const returned: unknown = callback(exec, result)
|
||||
void Promise.resolve(returned).catch(reportFailure)
|
||||
} catch (error: unknown) {
|
||||
this.ctx.logger.warn(`tool "${exec.name}" (${exec.callId}): tools/result observer failed: ${errorMessage(error)}`)
|
||||
reportFailure(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -487,7 +487,6 @@ describe('the run_code dispatch bridge', () => {
|
||||
additionalContexts: [{
|
||||
content: [{ type: 'text' as const, text: `context for ${exec.callId}` }],
|
||||
source: { kind: 'plugin' as const, plugin: 'test' },
|
||||
envelope: 'raw' as const,
|
||||
meta: { callId: exec.callId },
|
||||
}],
|
||||
})
|
||||
@@ -505,13 +504,11 @@ describe('the run_code dispatch bridge', () => {
|
||||
{
|
||||
content: [{ type: 'text', text: 'context for call-1:code:1' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
envelope: 'raw',
|
||||
meta: { callId: 'call-1:code:1' },
|
||||
},
|
||||
{
|
||||
content: [{ type: 'text', text: 'context for call-1:code:2' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
envelope: 'raw',
|
||||
meta: { callId: 'call-1:code:2' },
|
||||
},
|
||||
])
|
||||
|
||||
@@ -49,6 +49,19 @@ describe('gen-tool-catalog collectToolCatalog', () => {
|
||||
expect(bash?.source).toBe('packages/bash/tool-bash/src/index.ts')
|
||||
})
|
||||
|
||||
it('harvests search tools without depending on the generator process PATH', async () => {
|
||||
const oldPath = process.env.PATH
|
||||
try {
|
||||
process.env.PATH = ''
|
||||
const catalog = await collectToolCatalog()
|
||||
const search = catalog.find(entry => entry.pkg === '@deepseek-ai/dsh-tool-fs-search')
|
||||
expect(search?.schemas.map(s => s.name).sort()).toEqual(['glob', 'grep'])
|
||||
} finally {
|
||||
if (oldPath === undefined) delete process.env.PATH
|
||||
else process.env.PATH = oldPath
|
||||
}
|
||||
})
|
||||
|
||||
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`.
|
||||
|
||||
@@ -582,13 +582,18 @@ describe('scoped execution dispatch', () => {
|
||||
ctx.on('tools/result', () => {
|
||||
throw { toString: () => { throw new Error('coercion trap') } }
|
||||
})
|
||||
ctx.on('tools/result', () => Promise.reject(new Error('async observer failure')) as never)
|
||||
ctx.on('tools/result', (_exec, result) => { seen.push(result.isError) })
|
||||
|
||||
const result = await ctx.tools.execute({ callId: CallId('final'), name: 't', arguments: {}, agent: key })
|
||||
await Promise.resolve()
|
||||
expect(result).toMatchObject({ isError: true, content: [{ type: 'text', text: 'outer failure' }] })
|
||||
expect(seen).toEqual([true, true])
|
||||
expect(dispatchModes).toEqual(['emit'])
|
||||
expect(warn).toHaveBeenCalledOnce()
|
||||
expect(String(warn.mock.calls[0]?.[0])).toContain('<unprintable thrown value>')
|
||||
expect(warn).toHaveBeenCalledTimes(2)
|
||||
expect(warn.mock.calls.map(call => String(call[0]))).toEqual(expect.arrayContaining([
|
||||
expect.stringContaining('<unprintable thrown value>'),
|
||||
expect.stringContaining('async observer failure'),
|
||||
]))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -384,7 +384,7 @@ describe('ToolRegistry', () => {
|
||||
parameters: {},
|
||||
async execute(_args, exec) {
|
||||
exec.deferContext({ content: [{ type: 'text', text: 'nested-1' }], source: { kind: 'plugin', plugin: 'nested-1' }, meta: { n: 1 } })
|
||||
exec.deferContext({ content: [{ type: 'text', text: 'nested-2' }], source: { kind: 'plugin', plugin: 'nested-2' }, envelope: 'raw' })
|
||||
exec.deferContext({ content: [{ type: 'text', text: 'nested-2' }], source: { kind: 'plugin', plugin: 'nested-2' } })
|
||||
return [{ type: 'text', text: 'done' }]
|
||||
},
|
||||
}))
|
||||
@@ -418,7 +418,6 @@ describe('ToolRegistry', () => {
|
||||
{ kind: 'plugin', plugin: 'post' },
|
||||
])
|
||||
expect(result.additionalContexts?.[0]?.meta).toEqual({ n: 1 })
|
||||
expect(result.additionalContexts?.[1]?.envelope).toBe('raw')
|
||||
})
|
||||
|
||||
it('keeps deferred contexts when a composite tool throws, but drops them when the outer call is blocked', async () => {
|
||||
|
||||
Reference in New Issue
Block a user