feat(subagent): deliver continuable child settlement to parents

A continuable child that stopped without reporting — an error, a token
ceiling, cancellation, teardown — left its parent nothing to act on.
The continuation manager now delivers an unconditional settlement
notice to the durable direct parent before releasing ownership, folding
consumed work (foldConsumedWork supersedes findLastMessageTurnEnd) so a
claimed-but-unrun prompt reads as aborted rather than completed, waking
an idle parent, steering a busy one, and never waking a closing tree.
This commit is contained in:
Hypatia May
2026-08-11 12:31:49 +08:00
parent 76cf6cbd0b
commit 85dd22fd4f
102 changed files with 2139 additions and 345 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/core/agent/README.md
README.md: 0fb65b94a3b311aa9f0df09d39dd937cba4cc7b4
README.zh.md: 44f67483343a98c280317793ece544bd0b984596
README.md: 61294df5cca1b03f9f158678f2992a6e4fbaaffd
README.zh.md: 5576a07330926cfd8fd5bd42c8a0351a375f035c

View File

@@ -58,6 +58,8 @@ Inbox live notifications are deliberately per-message and minimal: `agent/inbox/
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).
`foldConsumedWork(events)` reads that feed back for the one question the turn sequence cannot answer alone: what became of the work a log consumed. It returns the latest `turn/end` that accounts for consumed work — a turn that entered a model step, or one that claimed inbox input and then failed, was stopped, or was rejected before reaching one — plus whether accepted work was later cancelled out of the inbox unrun. Both facts come from the log, so a cancellation reads the same whichever owner issued it. A no-step turn that took nothing, or emptied its claim and completed, describes no work and is skipped; a `blocked` end over claimed input is an account, because rejection discarded that input.
### Agent interface (`types.ts`)
The handle every plugin programs against:

View File

@@ -58,6 +58,8 @@ inbox 的实时通知刻意采用逐消息的最小载荷:`agent/inbox/inserte
轮次和步骤边界以及模型 token 流是持久 `session/event` 事实,而不是镜像的 `agent/*` 通知。消费方从会话事件流读取 `turn/*``step/*``assistant/chunk`;工具策略与结果观测属于 [`dsh-tools`](../tools/README.md) 记录的完整流水线。
`foldConsumedWork(events)` 把这条事件流读回来,回答仅凭轮次序列无法回答的那个问题:一份日志消费掉的工作最终怎样了。它返回能够为已消费工作作出交代的最新 `turn/end`——即进入过模型 step 的轮次,或者认领了 inbox 输入、但在进入 step 之前失败、被停下或被拒绝的轮次——并额外给出「已接受的工作此后是否被从 inbox 中取消且从未运行」。两项事实都来自日志,因此无论由哪个所有者发起取消,读出来都一样。没有取走任何输入、或认领批次被改写清空后正常结束的无 step 轮次不描述工作,会被跳过;认领过输入、以 `blocked` 结束的轮次则是一份交代,因为拒绝把这些输入一并丢弃了。
### Agent 接口(`types.ts`
每个插件面向的 handle

View File

@@ -0,0 +1,108 @@
/**
* How one agent log accounts for the work it consumed.
*
* The turn and step vocabulary alone cannot answer this. A turn that stops
* before its first step leaves a `turn/end` shaped exactly like the balanced
* no-op turns a rejection or an empty claim produces, so reading turns in
* isolation either credits cut-short work as finished or convicts every no-op.
* The missing fact is the inbox's own record: {@link Inbox} logs each mutation
* with `removedCount` and marks a cancellation `outcome: 'canceled'`, which
* separates a turn claiming its input from work being dropped unrun.
*
* @module @deepseek-ai/dsh-agent/consumed-work
*/
import type { SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session'
/** How one agent log accounts for the work it consumed. */
export interface ConsumedWork {
/**
* The latest closed turn that accounts for consumed work: one that entered a
* model step, or one that claimed inbox input and then failed, was stopped,
* or was rejected. Absent when no turn closed over any work.
*/
readonly end?: SessionEvent<'turn/end'>
/**
* Whether accepted work was cancelled out of the inbox, unrun, after that
* turn. This is the only account of input a cancellation took before any turn
* could open over it — no `turn/end` describes it.
*/
readonly droppedUnrun: boolean
}
/**
* Whether a turn that consumed input but never reached a step ends in a way
* that accounts for that input. Only a `completed` end does not: it had
* nothing left to run once its claim was rewritten away. A `blocked` end is
* that input's ending too — the pre-step rejection that produced it discarded
* the claimed messages, so the work it took will never run.
* @param reason - the turn's recorded ending.
* @returns whether the ending accounts for the input the turn took.
*/
function accountsForClaim(reason: TurnEndReason): boolean {
switch (reason.kind) {
case 'completed':
return false
case 'blocked':
case 'aborted':
case 'interrupted':
case 'error':
return true
/* v8 ignore next 4 -- unreachable: the one unnamed built-in, `max-tokens`, requires a step,
* so its turn short-circuits as stepped before this call, and `TurnEndReasonMap` is
* merge-extensible, so a backend-added variant cannot be listed; an unnameable ending over
* consumed input must not read as success. */
default:
return true
}
}
/**
* Fold one agent log, or an owned suffix of one, into its account of consumed
* work. Single pass, and every input is the log itself: no caller has to sample
* live state before cancelling, so a cancellation issued by anyone — the owner's
* teardown, an ancestor's interrupt, an unloading plugin — reads the same.
* @param events - the log, or an owned suffix, to fold.
* @returns the accounting turn when one closed, and whether work was dropped unrun after it.
*/
export function foldConsumedWork(events: readonly SessionEvent[]): ConsumedWork {
const stepped = new Set<number>()
const claimed = new Set<number>()
let open: number | undefined
let end: SessionEvent<'turn/end'> | undefined
let droppedUnrun = false
for (const event of events) {
switch (event.type) {
case 'turn/start':
open = event.data.turn
break
case 'step/start':
stepped.add(event.data.turn)
break
case 'agent/inbox/spliced': {
const { removedCount, outcome, inserted } = event.data
if (removedCount === undefined) break
// A replacement keeps the work pending under a new identity, so only a
// cancellation that leaves nothing behind drops it.
if (outcome === 'canceled') droppedUnrun ||= inserted.length === 0
// Claims are the loop's own step-boundary reads, always inside a turn.
else if (open !== undefined) claimed.add(open)
break
}
case 'turn/end': {
const { turn, reason } = event.data
open = undefined
if (stepped.delete(turn) || (claimed.delete(turn) && accountsForClaim(reason))) {
end = event
// Anything dropped before this turn closed is what its own ending
// reports; only a later drop is still unaccounted for.
droppedUnrun = false
}
break
}
default:
break
}
}
return { ...end === undefined ? {} : { end }, droppedUnrun }
}

View File

@@ -18,6 +18,7 @@ import type { Agent, AgentOptions } from './runtime-types.ts'
export * from './runtime-types.ts'
export * from './types.ts'
export * from './inbox.ts'
export * from './consumed-work.ts'
export * from './model-selection.ts'
export { agentCarrier, agentEvents, assembleContextFor, emitAgentEvent } from './dispatch.ts'
export type { AgentEventDispatch, AgentSubjectEvent } from './dispatch.ts'

View File

@@ -0,0 +1,160 @@
import { describe, expect, it } from 'vitest'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import type { TurnEndReason } from '@deepseek-ai/dsh-session'
import { foldConsumedWork } from '@deepseek-ai/dsh-agent'
/** One pending message, as the inbox records it. */
function message(text: string) {
return createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } })
}
/** Log an accepted message the way `Inbox.append()` does. */
function accept(session: Session, text: string): void {
session.append('agent/inbox/spliced', { target: 'next-turn', start: 0, inserted: [message(text)] })
}
/** Log the step-boundary read of one pending message, as `Inbox.claim()` does. */
function claim(session: Session): void {
session.append('agent/inbox/spliced', { target: 'next-turn', start: 0, removedCount: 1, inserted: [] })
}
/** Log a cancellation of one pending message, as `Inbox.clear()` does. */
function cancelPending(session: Session): void {
session.append('agent/inbox/spliced', {
target: 'next-turn', start: 0, removedCount: 1, inserted: [], outcome: 'canceled',
})
}
/** Run one whole turn that reached a model step. */
function steppedTurn(session: Session, turn: number, reason: TurnEndReason): void {
session.append('turn/start', { turn })
claim(session)
session.append('step/start', { turn, step: 1 })
session.append('step/end', { turn, step: 1 })
session.append('turn/end', { turn, reason })
}
describe('foldConsumedWork', () => {
it('reports nothing for a log that consumed no work', () => {
const session = Session.create(SessionId('empty'))
accept(session, 'queued')
expect(foldConsumedWork(session.events)).toEqual({ droppedUnrun: false })
})
it('reports the latest turn that entered a model step', () => {
const session = Session.create(SessionId('stepped'))
steppedTurn(session, 1, { kind: 'completed' })
steppedTurn(session, 2, { kind: 'max-tokens' })
expect(foldConsumedWork(session.events).end?.data)
.toEqual({ turn: 2, reason: { kind: 'max-tokens' } })
})
it('reports a turn that claimed its input and then failed before any step', () => {
const session = Session.create(SessionId('failed-claim'))
steppedTurn(session, 1, { kind: 'completed' })
// The step boundary runs the durability checkpoint and prompt assembly, so a
// turn can take its input and then fail without entering a step.
session.append('turn/start', { turn: 2 })
claim(session)
session.append('turn/end', { turn: 2, reason: { kind: 'error', error: { message: 'ENOSPC', code: 'UNKNOWN' } } })
expect(foldConsumedWork(session.events).end?.data.turn).toBe(2)
})
it('reports a turn that claimed its input and was then stopped before any step', () => {
const session = Session.create(SessionId('stopped-claim'))
steppedTurn(session, 1, { kind: 'completed' })
session.append('turn/start', { turn: 2 })
claim(session)
session.append('turn/end', { turn: 2, reason: { kind: 'aborted', reason: { kind: 'user' } } })
expect(foldConsumedWork(session.events).end?.data.turn).toBe(2)
})
it('ignores a turn stopped, failed, or rejected without taking any input', () => {
const session = Session.create(SessionId('no-claim'))
steppedTurn(session, 1, { kind: 'completed' })
session.append('turn/start', { turn: 2 })
session.append('turn/end', { turn: 2, reason: { kind: 'aborted', reason: { kind: 'parent' } } })
session.append('turn/start', { turn: 3 })
session.append('turn/end', { turn: 3, reason: { kind: 'error', error: { message: 'x', code: 'UNKNOWN' } } })
session.append('turn/start', { turn: 4 })
session.append('turn/end', { turn: 4, reason: { kind: 'blocked' } })
// None of these turns describes work: they opened, found nothing of their own, and closed.
expect(foldConsumedWork(session.events).end?.data.turn).toBe(1)
})
it('reports a turn whose claimed input a pre-step rejection discarded', () => {
const session = Session.create(SessionId('rejected-claim'))
steppedTurn(session, 1, { kind: 'completed' })
session.append('turn/start', { turn: 2 })
claim(session)
session.append('turn/end', { turn: 2, reason: { kind: 'blocked' } })
// Rejection does not retain the claimed messages, so the `blocked` end is
// the only account of input that will never run.
expect(foldConsumedWork(session.events).end?.data.turn).toBe(2)
})
it('ignores a claim its own turn emptied', () => {
const session = Session.create(SessionId('emptied-claim'))
steppedTurn(session, 1, { kind: 'completed' })
session.append('turn/start', { turn: 2 })
claim(session)
session.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
// An emptied claim ran nothing and dropped nothing: a listener rewrote the
// batch away, which is not this log's account of the work.
expect(foldConsumedWork(session.events).end?.data.turn).toBe(1)
})
it('credits a claim with no open turn to no turn at all', () => {
const session = Session.create(SessionId('mid-turn-suffix'))
steppedTurn(session, 1, { kind: 'completed' })
// An owned suffix can begin inside a turn whose start it does not contain,
// so a claim may appear with no turn to attribute it to.
claim(session)
session.append('turn/end', { turn: 2, reason: { kind: 'aborted', reason: { kind: 'user' } } })
expect(foldConsumedWork(session.events).end?.data.turn).toBe(1)
})
it('reports work cancelled out of the inbox after the last accounting turn', () => {
const session = Session.create(SessionId('dropped'))
steppedTurn(session, 1, { kind: 'completed' })
accept(session, 'never runs')
cancelPending(session)
// No turn opened over it, so only the cancellation says the work was cut short.
expect(foldConsumedWork(session.events)).toEqual({
end: session.events.find(event => event.type === 'turn/end'),
droppedUnrun: true,
})
})
it('keeps a replacement pending rather than counting it as dropped', () => {
const session = Session.create(SessionId('replaced'))
steppedTurn(session, 1, { kind: 'completed' })
session.append('agent/inbox/spliced', {
target: 'next-turn', start: 0, removedCount: 1, inserted: [message('rewritten')], outcome: 'canceled',
})
expect(foldConsumedWork(session.events).droppedUnrun).toBe(false)
})
it('lets a later accounting turn absorb an earlier drop', () => {
const session = Session.create(SessionId('absorbed'))
steppedTurn(session, 1, { kind: 'completed' })
cancelPending(session)
steppedTurn(session, 2, { kind: 'completed' })
expect(foldConsumedWork(session.events)).toEqual({
end: session.events.findLast(event => event.type === 'turn/end'),
droppedUnrun: false,
})
})
})