fix(agent-loop): address review — quiet-item parking, meta, discard balance

Resolve six review findings on the unified-send change:
- quiet (wakeup:false) queued items no longer un-park the driver; the
  inbox distinguishes hasWakingQueued (drives the loop, idle/quiescence)
  from hasQueued (anything to dequeue), so a lone quiet item parks at idle
  and rides the next waking send. whenIdle/cancel settle off the waking
  signal, so cancelling a parked quiet item no longer hangs whenIdle.
- SendOptions.meta on queued/steering sends now reaches the durable
  user/message and steering/message (was dropped except on injection).
- a terminal agent/turn-stop that drops pending steering emits
  agent/inbox/discard so the enqueue-dequeue-or-discard ledger balances.
- the loop-authored continuation reason is snapshotted and frozen like a
  public send.
- gen-cordis-api collects exported classes (body-stripped) so the now-
  abstract-class Agent and its transitive shapes reappear in the API
  catalog.

Adds regression tests for each and re-records the affected snapshot.
This commit is contained in:
Turtle
2026-07-23 21:41:42 +08:00
parent c402cc2422
commit 98ee4ce429
13 changed files with 245 additions and 24 deletions

View File

@@ -201,7 +201,10 @@ export class ReactLoopAgent extends Agent {
id: AgentMessageId, content: ContentBlock[], source: MessageSource, wakeup: boolean, options?: SendOptions,
): InboxMessage {
const contexts = options?.contexts ?? []
const accepted = snapshotJsonValue({ id, content, source, contexts, wakeup })
const accepted = snapshotJsonValue({
id, content, source, contexts, wakeup,
...options?.meta !== undefined ? { meta: options.meta } : {},
})
if (accepted === undefined) {
throw new TypeError('agent message content, source, and contexts must be losslessly JSON-serializable')
}
@@ -344,6 +347,11 @@ export class ReactLoopAgent extends Agent {
agentEvents(this.loopCtx, this).emit('agent/cancel-requested', resolvedCause)
}
if (!keepInbox) {
// Whether the parked driver was already scheduled to run: a waking item
// woke `waitForQueued`, so the loop WILL resume and settle idle waiters
// itself through the pre-run-cancel path (possibly after a replacement
// prompt). Only a lone quiet item leaves the loop truly parked.
const willResume = this.#inbox.hasWakingQueued
// Snapshot before clearing so the discard notification carries the exact
// dropped items; a replacement synchronously enqueued by an
// `agent/cancel-requested` observer belongs to the next turn, not here.
@@ -354,6 +362,15 @@ export class ReactLoopAgent extends Agent {
const items = discarded.map(({ message, steering }) => agentMessage(message, steering))
agentEvents(this.loopCtx, this).emit('agent/inbox/discard', items)
}
// Clearing a parked quiet (`wakeup:false`) item reaches quiescence with no
// status transition and without waking the parked driver, so settle any
// `whenIdle` waiter here. When a waking item was present the loop resumes
// and settles itself; while `running` (including the post-turn flush
// window) the driver still owns the eventual idle transition. So settle
// only for a parked, non-running agent whose sole cleared work was quiet.
if (cancellation === undefined && !willResume && this._status !== 'running') {
this.settleIdleWaiters()
}
}
cancellation?.request(resolvedCause)
}
@@ -365,7 +382,9 @@ export class ReactLoopAgent extends Agent {
*/
whenIdle(): Promise<void> {
if (this._status === 'disposed') return this.done
if (this._status !== 'running' && !this.#inbox.hasQueued) return Promise.resolve()
// A lone quiet (`wakeup:false`) queued item leaves the agent quiescent — the
// driver stays parked — so gate on hasWakingQueued, not hasQueued.
if (this._status !== 'running' && !this.#inbox.hasWakingQueued) return Promise.resolve()
// Agent-owned waiters survive concurrent fiber disposal.
return new Promise<void>((resolve) => {
this.idleWaiters.push(() => {

View File

@@ -7,6 +7,7 @@
*/
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
import type { JsonValue } from '@deepseek-ai/dsh-session'
import type { AgentMessage, AgentMessageId, HookContext } from '@deepseek-ai/dsh-agent'
/** One message waiting in an agent's inbox; `id` is the value `send` returned. */
@@ -17,6 +18,8 @@ export interface InboxMessage {
contexts: HookContext[]
/** Whether the item is marked to wake the driver or force a continuation. */
wakeup: boolean
/** Opaque durable JSON state retained on the durable message but hidden from the model. */
meta?: JsonValue
}
/**
@@ -39,11 +42,22 @@ export class Inbox {
private steeringMessages: InboxMessage[] = []
private wakeup: (() => void) | undefined
/** True while queued messages are pending — read by the idle wait's fast path and the loop's turn-start checks. */
/** True while any queued message is pending — read by cancellation's discard snapshot and the turn-start dequeue guard. */
get hasQueued(): boolean {
return this.queuedMessages.length > 0
}
/**
* True while a queued message wants to wake the driver — the "should the loop
* run" signal read by the idle wait's fast path, the loop's idle-publish
* check, and `whenIdle`. A `wakeup:false` (quiet) item alone leaves this
* false, so the driver stays parked until a waking send (or a waking item
* ahead of it in FIFO order) drives the loop; the quiet item then rides along.
*/
get hasWakingQueued(): boolean {
return this.queuedMessages.some(message => message.wakeup)
}
/** True while steering messages are pending — read by cancellation and the loop's stop-override check. */
get hasSteering(): boolean {
return this.steeringMessages.length > 0
@@ -116,7 +130,7 @@ export class Inbox {
* loop can exit).
*/
waitForQueued(cancel: Promise<void>): Promise<void> {
if (this.hasQueued) return Promise.resolve()
if (this.hasWakingQueued) return Promise.resolve()
const { promise, resolve } = Promise.withResolvers<void>()
this.wakeup = resolve
void cancel.then(resolve)

View File

@@ -202,9 +202,11 @@ export async function runLoop(ctx: Context, handle: LoopHandle): Promise<void> {
while (!handle.isDisposed()) {
// An idle listener can enqueue and cancel replacement work before the next
// wait is installed. Consume that empty marker before parking the driver.
// A quiet (`wakeup:false`) item alone must not un-park the loop, so gate on
// hasWakingQueued, not hasQueued.
if (handle.isPreRunCancelled()) {
handle.clearPreRunCancel()
if (!handle.inbox.hasQueued) {
if (!handle.inbox.hasWakingQueued) {
handle.settleIdle()
handle.setStatus('idle')
continue
@@ -218,7 +220,7 @@ export async function runLoop(ctx: Context, handle: LoopHandle): Promise<void> {
// a replacement prompt still runs before the eventual idle transition.
if (handle.isPreRunCancelled()) {
handle.clearPreRunCancel()
if (!handle.inbox.hasQueued) {
if (!handle.inbox.hasWakingQueued) {
// Settle before publishing idle: the already-idle path has no status
// transition, while an idle listener can register waiters for new work.
handle.settleIdle()
@@ -235,10 +237,11 @@ export async function runLoop(ctx: Context, handle: LoopHandle): Promise<void> {
}
// A synchronous `running` listener can cancel before `runTurn`; balance the
// status only when no replacement prompt was queued by that listener.
// status only when no waking replacement prompt was queued by that listener
// (a lone quiet item parks at idle rather than driving a turn).
if (cancellation.signal.aborted) {
handle.clearTurnCancellation(cancellation)
if (!handle.inbox.hasQueued) {
if (!handle.inbox.hasWakingQueued) {
handle.setStatus('idle')
continue
}
@@ -266,7 +269,9 @@ export async function runLoop(ctx: Context, handle: LoopHandle): Promise<void> {
if (!terminalStopped) handle.inbox.enqueue(message)
}
if (!handle.inbox.hasQueued) handle.setStatus('idle')
// Park at idle unless a waking item still wants the model to run; a lone
// quiet (`wakeup:false`) item stays queued but does not keep the loop busy.
if (!handle.inbox.hasWakingQueued) handle.setStatus('idle')
}
}
@@ -282,7 +287,10 @@ async function runTurn(
for (const message of messages) {
events.emit('agent/inbox/dequeue', agentMessage(message, true))
const prepared = preparePromptMessage(message.content, message.source, message.contexts)
session.append('steering/message', { turn, ...prepared.data }, { surfaceOp: 'append' })
session.append('steering/message', {
turn, ...prepared.data,
...message.meta === undefined ? {} : { meta: message.meta },
}, { surfaceOp: 'append' })
for (const context of prepared.separateContexts) {
session.append('user/message', {
content: context.content,
@@ -364,7 +372,10 @@ async function runTurn(
// `allow.content` REPLACES the prompt bytes (a rewrite); absent keeps them.
const content = promptDecision.content ?? message.content
const prepared = preparePromptMessage(content, message.source, promptDecision.additionalContexts ?? [])
session.append('user/message', prepared.data, { surfaceOp: 'append' })
session.append('user/message', {
...prepared.data,
...message.meta === undefined ? {} : { meta: message.meta },
}, { surfaceOp: 'append' })
// Separate contexts still enter THIS turn through inject(). Prefix
// contexts are already baked into the user/message with their durable
// display envelope, so appending them again would duplicate model input.
@@ -543,10 +554,15 @@ async function runTurn(
// enqueue event a public steer would, so the inbox ledger stays balanced
// (every FIFO entry has a matching enqueue before its dequeue/discard).
if (decision.action === 'continue' && decision.reason) {
const item: InboxMessage = {
id: AgentMessageId(randomUUID()), content: decision.reason.content,
source: decision.reason.source, contexts: [], wakeup: true,
}
// Detach and freeze the listener-owned reason like a public steer, so an
// enqueue listener or the producer cannot mutate the durable/model-visible
// steering message before it drains.
const item: InboxMessage = deepFreeze({
id: AgentMessageId(randomUUID()),
content: structuredClone(decision.reason.content),
source: structuredClone(decision.reason.source),
contexts: [], wakeup: true,
})
handle.inbox.steer(item)
events.emit('agent/inbox/enqueue', agentMessage(item, true))
}
@@ -572,7 +588,13 @@ async function runTurn(
if (terminalStop) {
terminalStopped = true
// Terminal stop discards steering but preserves ordinary queued prompts.
handle.inbox.drainSteering()
// Publish a discard for every dropped steering item so the enqueue ⇒
// dequeue-or-discard ledger stays balanced (the outstanding-count
// invariant and correlation consumers must not be left with dangling ids).
const dropped = handle.inbox.drainSteering()
if (dropped.length > 0) {
events.emit('agent/inbox/discard', dropped.map(item => agentMessage(item, true)))
}
shouldContinue = false
}

View File

@@ -117,6 +117,38 @@ describe('Agent.cancel()', () => {
expect(userTexts(agent)).toEqual(['preserved', 'wake it'])
})
it('a lone quiet (wakeup:false) send leaves the agent parked at idle', async () => {
const adapter = new MockAdapter([textResponse('reply')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
// A quiet item alone must NOT wake the driver: no turn runs and whenIdle
// resolves (the agent is quiescent), leaving the item queued.
agent.send([{ type: 'text', text: 'quiet' }], { target: 'next-turn', wakeup: false })
await agent.whenIdle()
expect(agent.status).toBe('idle')
expect(agent.session.events.some(e => e.type === 'turn/start')).toBe(false)
// A later waking send drives the loop, and the quiet item rides along first.
send(agent, 'wake')
await waitForIdle(ctx, agent)
expect(userTexts(agent)).toEqual(['quiet', 'wake'])
})
it('cancelling a parked quiet item settles a pending whenIdle() without a later send', async () => {
const adapter = new MockAdapter([textResponse('reply')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'quiet' }], { target: 'next-turn', wakeup: false })
const idle = agent.whenIdle()
// Cancel reaches quiescence with no status transition and no waking send;
// whenIdle must still resolve (previously it hung until the next send).
agent.cancel({ kind: 'user' })
await idle
expect(agent.session.events.some(e => e.type === 'turn/start')).toBe(false)
})
it('pre-step cancel drops the about-to-start turn (no turn is opened)', async () => {
const adapter = new MockAdapter([textResponse('should not run')])
const ctx = await harness(adapter)

View File

@@ -86,4 +86,35 @@ describe('inbox FIFO-conservation invariant', () => {
expect(warn.mock.calls.flat().some(arg => String(arg).includes('agent/inbox'))).toBe(false)
expect(warn.mock.calls.flat().some(arg => String(arg).includes('INVARIANT'))).toBe(false)
})
it('stays balanced when a terminal stop discards pending steering', async () => {
const adapter = new MockAdapter([textResponse('done')])
const ctx = await harness(adapter)
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const discards: number[] = []
ctx.on('agent/inbox/discard', (subject, messages) => { if (subject === agent) discards.push(messages.length) })
// A continuation reason enqueues a steering item; a terminal stop then drops
// it. The drop must emit a discard so the enqueue ⇒ dequeue-or-discard
// ledger stays balanced (no dangling outstanding id).
ctx.on('agent/turn-continuation', async (subject, _turn, _default, _signal, next) => {
if (subject !== agent) return next()
return { action: 'continue' as const, reason: { content: [{ type: 'text', text: 'keep going' }], source: { kind: 'plugin', plugin: 'loop' } } }
})
let stopped = false
ctx.on('agent/turn-stop', (subject) => {
if (subject !== agent || stopped) return undefined
stopped = true
return { action: 'stop' as const }
})
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
expect(discards).toEqual([1]) // the dropped steering item was reported
expect(warn.mock.calls.flat().some(arg => String(arg).includes('agent/inbox'))).toBe(false)
expect(warn.mock.calls.flat().some(arg => String(arg).includes('INVARIANT'))).toBe(false)
})
})

View File

@@ -526,6 +526,28 @@ describe('agent loop', () => {
expect(agent.session.events.some(event => event.type === 'user/message' && event.data.source.kind === 'plugin')).toBe(false)
})
it('preserves SendOptions.meta on the durable user/message and steering/message', async () => {
const adapter = new MockAdapter([toolCallResponse('c1', 'noop', {}), textResponse('done')])
const ctx = await harness(adapter)
ctx.tools.register(defineContentToolFixture({
name: 'noop', description: '', parameters: {},
async execute() {
// Running steer carries its own meta onto the durable steering/message.
agent.steer([{ type: 'text', text: 's' }], { source: { kind: 'plugin', plugin: 'p' }, meta: { steer: 1 } })
return []
},
}))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }], { target: 'next-turn', wakeup: true, meta: { prompt: 1 } })
await waitForIdle(ctx, agent)
const user = agent.session.events.find(e => e.type === 'user/message')
expect(user?.type === 'user/message' && user.data.meta).toEqual({ prompt: 1 })
const steering = agent.session.events.find(e => e.type === 'steering/message')
expect(steering?.type === 'steering/message' && steering.data.meta).toEqual({ steer: 1 })
})
it('agent/turn-continuation can force-continue (/loop pattern) and force-stop', async () => {
// force-continue: model never calls tools, but a plugin forces 3 steps
const adapter = new MockAdapter([