fix(subagent): close continuation lifecycle gaps

This commit is contained in:
Dudu-0223
2026-07-30 21:21:14 +08:00
committed by Tianyi Cui
parent 853f4d5cfb
commit a91b20f6be
29 changed files with 365 additions and 142 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/subagent/subagent/README.md
README.md: 1b38d493efa1dbe86464ad376649ff37914067da
README.zh.md: ec907f466779fc5c8a503f003a50f4aaf41c8b49
README.md: 0e59a1ad5f256de4d6505d3d00d3790d7738a457
README.zh.md: 073b4903520544e1b5b9209f792aa5e05d9334b0

View File

@@ -80,7 +80,7 @@ A continuation-managed parent Activation records each child Session id in an `ow
## Lifecycle events
The service emits a `subagent/start`/`subagent/end` pair for each one-shot run and each resident continuable Activation epoch, so continuable children are observable with the same vocabulary as one-shot runs without exposing whether the manager materialized, woke, or cold-resumed them. For a one-shot start it attaches the result observer before the synchronous `subagent/start`, so even an already-settled child still produces `subagent/start` before `subagent/end`; a continuable epoch that fails before residency emits neither edge. The pair shares a service-minted `runId`; the `local` flag is snapshotted from the provider's exact `localAgent` (always true for a continuable child), so observers never infer run identity or locality from reusable provider/session names.
The service emits a `subagent/start`/`subagent/end` pair for each one-shot run and each resident continuable Activation epoch, so continuable children are observable with the same vocabulary as one-shot runs without exposing whether the manager materialized, woke, or cold-resumed them. For a one-shot start it attaches the result observer before the synchronous `subagent/start`, so even an already-settled child still produces `subagent/start` before `subagent/end`; a continuable epoch that fails before residency emits neither edge. The pair shares a service-minted `runId`; the `local` flag is snapshotted from the provider's exact `localAgent` (always true for a continuable child), so observers never infer run identity or locality from reusable provider/session names. The `provider` field is lifecycle provenance rather than a live-registry claim: an accepted one-shot run may become ready after provider removal, and a cold-resumed epoch retains its descriptor's initial provider name without requiring that provider to be registered.
Run events are scoped to the delegating parent. Every listener is independently contained: a synchronous throw or rejected returned promise is logged without starving peer listeners or changing the run.

View File

@@ -80,7 +80,7 @@ subagent seam 允许一个 agent智能体通过具名提供方把工作委
## 生命周期事件
服务会为每次一次性运行以及每个已驻留的可继续 Activation 时段发出一对 `subagent/start`/`subagent/end`,因此可继续子 agent 可用与一次性运行相同的词汇观察,且不会暴露管理器是物化、唤醒还是冷恢复了它们。对于一次性启动,它会在同步的 `subagent/start` 之前附加结果观察器,因此即使子 agent 已经结算,也仍会先产生 `subagent/start`,再产生 `subagent/end`;在驻留前失败的可继续时段不发出任何事件。这对事件共享服务生成的 `runId``local` 标志取自提供方准确 `localAgent` 的快照(可继续子 agent 恒为 true因此观察器绝不会从可复用的提供方/会话名称推断运行身份或本地性。
服务会为每次一次性运行以及每个已驻留的可继续 Activation 时段发出一对 `subagent/start`/`subagent/end`,因此可继续子 agent 可用与一次性运行相同的词汇观察,且不会暴露管理器是物化、唤醒还是冷恢复了它们。对于一次性启动,它会在同步的 `subagent/start` 之前附加结果观察器,因此即使子 agent 已经结算,也仍会先产生 `subagent/start`,再产生 `subagent/end`;在驻留前失败的可继续时段不发出任何事件。这对事件共享服务生成的 `runId``local` 标志取自提供方准确 `localAgent` 的快照(可继续子 agent 恒为 true因此观察器绝不会从可复用的提供方/会话名称推断运行身份或本地性。`provider` 字段是生命周期来源信息,而非提供方仍在注册的声明:已接受的一次性 run 可在提供方移除后才进入就绪状态,冷恢复时段也会保留描述符中的初始提供方名称,而不要求该提供方仍处于注册状态。
运行事件受执行委派的父级作用域约束。每个监听器都独立隔离:同步抛出或返回的 promise 被拒绝时,只会记录日志,不会阻塞同级监听器或改变运行。

View File

@@ -36,6 +36,7 @@ import {
resolveChildAgentOptions,
resolveChildDepth,
} from './child-agent.ts'
import { assertSubagentMaxDepth } from './depth.ts'
import { seedDescriptorTurn } from './descriptor-seed.ts'
import type { ContinuableCreateRequest, ContinuableCreateSpec, SubagentStartRequest } from './types.ts'
import type { ActivationObserver } from './lifecycle.ts'
@@ -248,6 +249,7 @@ export class SubagentContinuationManager {
this.requirePersistence()
const request = spec.request
const parent = request.parent
assertSubagentMaxDepth(request.maxDepth)
const childId = SessionId(randomUUID())
const childDepth = resolveChildDepth(parent, request.maxDepth)
// Snapshot before any await: invalid descriptor JSON rejects the call
@@ -282,11 +284,13 @@ export class SubagentContinuationManager {
composition: { persona: request.persona, toolFilter: request.toolFilter },
signal: spec.signal,
})
// Materialization published the Activation; an abort landing in that
// window — a `subagent/start` listener can cancel synchronously — must
// roll the child back instead of opening its first turn.
await this.rollbackIfAborted(activation, spec.signal)
return this.submit(activation, request.prompt, { kind: 'user' }, parent)
return this.submitMaterialized(
activation,
request.prompt,
{ kind: 'user' },
parent,
spec.signal,
)
})
return { childId, messageId }
}
@@ -328,13 +332,8 @@ export class SubagentContinuationManager {
if (activation.disposal !== undefined) {
return activation.disposal.then(() => undefined, () => undefined)
}
await this.authorizeLive(parent, activation)
// The caller signal owns admission until acceptance, so re-check it
// here: the outer check cannot cover an abort that landed while
// authorization yielded, and enqueueing afterwards would return a
// message id for a delivery the caller already cancelled.
options.signal.throwIfAborted()
return this.submit(activation, content, options.source, parent)
this.authorizeLive(parent, activation)
return this.submitAdmitted(activation, content, options.source, parent, options.signal)
})
/* v8 ignore start -- only the lost-cutoff arm above returns undefined, so only that
* race reaches the retry below, which then cold-resumes a new Activation. */
@@ -455,23 +454,33 @@ export class SubagentContinuationManager {
composition: { persona: descriptor.persona, toolFilter: descriptor.toolFilter },
signal: options.signal,
})
await this.rollbackIfAborted(activation, options.signal)
return this.submit(activation, content, options.source, parent)
return this.submitMaterialized(activation, content, options.source, parent, options.signal)
}
/**
* Dispose a freshly materialized Activation when the caller signal won the
* handoff between publication and inbox acceptance, so an aborted operation
* never leaves a resident child.
* @param activation - the just-published Activation.
* @param signal - the caller signal owning admission until acceptance.
* Submit to a freshly materialized Activation or roll it back completely.
* @param activation - the just-published Activation to admit or release.
* @param content - the initial or resumed message content.
* @param source - durable provenance for the accepted message.
* @param parent - the live direct parent authorizing admission.
* @param signal - caller cancellation owning admission until acceptance.
* @returns the accepted inbox message id.
*/
private async rollbackIfAborted(activation: Activation, signal: AbortSignal): Promise<void> {
if (!signal.aborted) return
/* v8 ignore next -- the swallow only covers a disposal fault during rollback, which
* must not mask the caller's abort as the operation's failure. */
await this.dispose(activation).catch(() => undefined)
signal.throwIfAborted()
private async submitMaterialized(
activation: Activation,
content: ContentBlock[],
source: MessageSource,
parent: Agent,
signal: AbortSignal,
): Promise<MessageId> {
try {
return this.submitAdmitted(activation, content, source, parent, signal)
} catch (error: unknown) {
/* v8 ignore next -- rollback disposal failures must not mask the
* pre-acceptance signal, drain, or lifecycle failure. */
await this.dispose(activation).catch(() => undefined)
throw error
}
}
/**
@@ -498,30 +507,24 @@ export class SubagentContinuationManager {
inputs.signal.throwIfAborted()
const setup = (childCtx: Context): void => { applyChildComposition(childCtx, inputs.composition) }
const observer = this.host.observeActivation(provider, childId, parent)
let handle: AgentHandle
try {
const { create } = inputs
handle = create === undefined
? await this.ownerCtx.agents.resume({
resumeSessionId: childId,
agentOptions: inputs.agentOptions,
signal: inputs.signal,
setup,
})
: await this.ownerCtx.agents.create({
sessionId: childId,
meta: create.meta,
seed: create.seed,
agentOptions: inputs.agentOptions,
signal: inputs.signal,
setup,
})
} catch (error: unknown) {
// Agent creation provides rollback before handle transfer, so nothing
// outlives this rejection; report the epoch that never became resident.
// No start edge was published, so this epoch has no lifecycle to close.
throw error
}
const { create } = inputs
// Agent creation owns rollback before handle transfer. A rejection leaves
// no resident Activation and therefore publishes no lifecycle edge.
const handle: AgentHandle = create === undefined
? await this.ownerCtx.agents.resume({
resumeSessionId: childId,
agentOptions: inputs.agentOptions,
signal: inputs.signal,
setup,
})
: await this.ownerCtx.agents.create({
sessionId: childId,
meta: create.meta,
seed: create.seed,
agentOptions: inputs.agentOptions,
signal: inputs.signal,
setup,
})
const activation: Activation = {
childId,
@@ -540,42 +543,53 @@ export class SubagentContinuationManager {
inputs.signal.throwIfAborted()
this.assertAdmitting()
this.acquireOwnership(parent, childId)
// Every accepted id leaves the inbox exactly once, through dequeue or
// discard. Clearing it there is what lets `stateOf()` distinguish a truly
// quiet Agent from one whose accepted turn has not been admitted yet.
// Registered through the child's own scoped context, so scope filtering
// already restricts both listeners to this exact agent.
handle.agent.ctx.on('agent/inbox/dequeue', (_agent, item) => {
/* v8 ignore next -- a dequeue of an id this manager never admitted needs
* another sender on the same child, which no current path allows. */
if (activation.accepted.delete(item.message.id)) this.wake(activation)
})
handle.agent.ctx.on('agent/inbox/discard', (_agent, items) => {
// Deleting every id in the batch is unconditional; waking once afterwards
// costs nothing and avoids branching on which ids this manager admitted.
for (const item of items) activation.accepted.delete(item.message.id)
this.wake(activation)
})
// Resident: publish the start edge before any turn can run, so observers
// see this epoch before its first request.
observer.start(handle.agent)
} catch (error: unknown) {
// Roll the transfer back completely: the Activation leaves the map, the
// parent's ownership membership is released, and the created handle is
// disposed before this rejection surfaces. No lifecycle edge is published,
// because `observer.start()` below has not run for this epoch.
this.activations.delete(childId)
this.releaseOwnership(childId)
activation.disposal = handle.dispose()
/* v8 ignore next -- the created handle disposes cleanly on every rollback this
* transaction can reach; the catch only keeps a disposal fault from masking `error`. */
await activation.disposal.catch(() => undefined)
// Listener exceptions are contained by the lifecycle emitter; a start
// publication throw therefore leaves no residency edge to pair.
/* v8 ignore next -- rollback failure must not mask the admission failure
* that prevented this operation from returning an accepted message id. */
await this.rollbackUnpublished(activation).catch(() => undefined)
throw error
}
// Every accepted id leaves the inbox exactly once, through dequeue or
// discard. Clearing it there is what lets `stateOf()` distinguish a truly
// quiet Agent from one whose accepted turn has not been admitted yet.
// Registered through the child's own scoped context, so scope filtering
// already restricts both listeners to this exact agent.
handle.agent.ctx.on('agent/inbox/dequeue', (_agent, item) => {
/* v8 ignore next -- a dequeue of an id this manager never admitted needs
* another sender on the same child, which no current path allows. */
if (activation.accepted.delete(item.message.id)) this.wake(activation)
})
handle.agent.ctx.on('agent/inbox/discard', (_agent, items) => {
// Deleting every id in the batch is unconditional; waking once afterwards
// costs nothing and avoids branching on which ids this manager admitted.
for (const item of items) activation.accepted.delete(item.message.id)
this.wake(activation)
})
// Resident: publish the start edge before any turn can run, so observers
// see this epoch before its first request.
observer.start(handle.agent)
this.watchSettlement(activation)
return activation
}
/**
* Release an Activation whose start edge was not published. The memoized
* transaction remains in the live map until handle disposal settles, so a
* concurrent drain or delivery observes the same closing boundary.
*/
private rollbackUnpublished(activation: Activation): Promise<void> {
return (activation.disposal ??= (async () => {
try {
await activation.handle.dispose()
} finally {
this.activations.delete(activation.childId)
this.releaseOwnership(activation.childId)
}
})())
}
/**
* Register the child in a continuation-managed parent's owned set before the
* child can run, so that parent cannot settle while the child is live. A
@@ -637,12 +651,36 @@ export class SubagentContinuationManager {
return message.id
}
/**
* Cross the final admission cutoff and submit without yielding. Signal abort,
* manager drain, or Activation disposal that wins before this synchronous
* span rejects without inbox acceptance.
*/
private submitAdmitted(
activation: Activation,
content: ContentBlock[],
source: MessageSource,
parent: Agent,
signal: AbortSignal,
): MessageId {
signal.throwIfAborted()
this.assertAdmitting()
/* v8 ignore next 6 -- only a synchronous re-entrant disposer can change
* this field between the caller's live check and this no-await boundary. */
if (disposalOf(activation) !== undefined) {
throw new SubagentError(
`subagent "${activation.childId}" activation is being disposed; the message was not accepted`,
'ACTIVATION_CLOSING',
)
}
return this.submit(activation, content, source, parent)
}
/**
* Authorize delivery to a live Activation. A parent must be the exact live
* direct parent recorded in the child's durable header.
*/
private async authorizeLive(parent: Agent, activation: Activation): Promise<void> {
await Promise.resolve()
private authorizeLive(parent: Agent, activation: Activation): void {
this.authorizeLineage(
parent,
activation.childId,
@@ -762,6 +800,12 @@ export class SubagentContinuationManager {
// Capture the child-dependent edge data while the child is still live:
// handle disposal unregisters it, and consumers read its log and scope.
activation.observer.capture(activation.handle.agent)
} catch (error: unknown) {
failure ??= new SubagentError(
`subagent "${childId}" activation teardown failed: ${errorChain(error)}`,
'ACTIVATION_TEARDOWN_FAILED',
{ cause: error },
)
} finally {
try {
await activation.handle.dispose()

View File

@@ -303,7 +303,7 @@ export class SubagentService extends Service {
return provider
}
/** Resolve the optional Task-backed continuation runtime or fail loud. */
/** Resolve the optional continuable-subagent manager or fail loud. */
private requireContinuations(): SubagentContinuationManager {
if (this.continuations === undefined) {
throw new SubagentError(

View File

@@ -43,9 +43,11 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant
}
if (eventName === 'subagent/start') {
const info = args[0] as SubagentRunInfo
if (!providers.has(info.provider)) fail(`subagent/start names inactive provider ${JSON.stringify(info.provider)}`)
if (String(info.runId).length === 0 || String(info.id).length === 0) {
fail('subagent/start runId and child id must be non-empty')
// Provider availability is an admission-time relationship. A ready
// one-shot run may outlive provider removal, and a cold-resumed Activation
// carries durable provider provenance without dispatching through it.
if (info.provider.length === 0 || String(info.runId).length === 0 || String(info.id).length === 0) {
fail('subagent/start provider, runId, and child id must be non-empty')
}
if (runs.has(info.runId)) fail(`subagent/start repeated run id ${JSON.stringify(info.runId)}`)
stagedStarts.add(info)

View File

@@ -35,7 +35,11 @@ export function SubagentRunId(id: string): SubagentRunId {
export interface SubagentRunInfo {
/** Unique identity shared with the paired terminal event. */
readonly runId: SubagentRunId
/** The provider that established the run. */
/**
* Provider provenance for this run or Activation epoch. The named provider
* may be absent when an accepted run becomes ready or a persisted Activation
* cold-resumes, because neither lifecycle depends on continued registration.
*/
readonly provider: string
/** The child agent's id. */
readonly id: SessionId
@@ -50,7 +54,7 @@ export interface SubagentRunInfo {
export interface SubagentRunEndInfo {
/** Unique identity shared with the paired start event. */
readonly runId: SubagentRunId
/** The provider that ran it. */
/** The same provider provenance carried by the paired start event. */
readonly provider: string
/** The child agent's id. */
readonly id: SessionId

View File

@@ -14,12 +14,14 @@ import * as SubagentFork from '@deepseek-ai/dsh-subagent-fork'
import type { GenerateOptions, MessageId, StreamChunk } from '@deepseek-ai/dsh-llm'
import { LlmAdapter } from '@deepseek-ai/dsh-llm'
import { defineTool } from '@deepseek-ai/dsh-tools'
import InvariantService from '@deepseek-ai/dsh-invariants'
import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import SubagentService, {
SubagentError,
SUBAGENT_DESCRIPTOR_VERSION,
} from '../src/index.ts'
import type { SubagentRunEndInfo, SubagentRunInfo } from '../src/index.ts'
import * as SubagentInvariant from '../src/invariant.ts'
type Script = ConstructorParameters<typeof MockAdapter>[0]
@@ -228,6 +230,24 @@ describe('SubagentService.startContinuable', () => {
})
})
it('rolls an unpublished Activation back when lifecycle publication fails', async () => {
const { ctx, parent } = await setup([textResponse('unused')])
const ends: SubagentRunEndInfo[] = []
ctx.on('subagent/end', info => void ends.push(info))
ctx.on('internal/dispatch', (_mode, eventName) => {
if (eventName === 'subagent/start') throw new Error('start publication failed')
}, { global: true })
await expect(ctx.subagents.startContinuable(startSpec(parent)))
.rejects.toThrow(/start publication failed/)
await vi.waitFor(() => {
expect(ctx.agents.list().map(agent => agent.id)).toEqual([SessionId('parent')])
})
expect(ends).toEqual([])
await expect(ctx.subagents.drainContinuable()).resolves.toBeUndefined()
})
it('rejects a continuable child that would exceed the configured depth cap', async () => {
const { ctx, parent } = await setup([])
await expect(ctx.subagents.startContinuable({
@@ -237,6 +257,15 @@ describe('SubagentService.startContinuable', () => {
expect(ctx.agents.list().map(agent => agent.id)).toEqual([SessionId('parent')])
})
it('rejects an invalid continuable depth cap before provider preparation', async () => {
const { ctx, parent } = await setup([])
await expect(ctx.subagents.startContinuable({
...startSpec(parent),
request: { prompt: message('deep'), parent, maxDepth: Number.NaN },
})).rejects.toThrow(/non-negative safe integer/)
expect(ctx.agents.list().map(agent => agent.id)).toEqual([SessionId('parent')])
})
it('omits undeclared composition fields from the descriptor', async () => {
const { ctx } = await setup([])
// A routeless parent declares no provider/model, and this start declares no
@@ -402,6 +431,38 @@ describe('SubagentService.followup residency routing', () => {
expect(loaded.events.filter(event => event.type === 'subagent/descriptor')).toHaveLength(1)
})
it('cold-resumes after the initial provider unregisters', async () => {
const { ctx, parent } = await setup([textResponse('first'), textResponse('after resume')])
await ctx.plugin(InvariantService)
await ctx.plugin(SubagentInvariant)
const disposeProvider = ctx.subagents.registerProvider({
name: 'retired',
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
inheritsParentContext: false,
start: async () => { throw new Error('one-shot start is not used') },
prepareContinuable: () => Promise.resolve({}),
})
const starts: SubagentRunInfo[] = []
const ends: SubagentRunEndInfo[] = []
ctx.on('subagent/start', info => void starts.push(info))
ctx.on('subagent/end', info => void ends.push(info))
const started = await ctx.subagents.startContinuable(startSpec(parent, 'retired'))
await waitNoActivation(ctx, started.childId)
disposeProvider()
expect(ctx.subagents.getProvider('retired')).toBeUndefined()
await expect(followup(ctx, parent, started.childId, message('continue without provider')))
.resolves.toBeTypeOf('string')
await waitNoActivation(ctx, started.childId)
await vi.waitFor(() => { expect(ends).toHaveLength(2) })
expect(starts.map(info => info.provider)).toEqual(['retired', 'retired'])
expect(ends.map(info => info.runId)).toEqual(starts.map(info => info.runId))
const loaded = await ctx.sessionPersistence.load(started.childId)
expect(userTexts(loaded.events)).toEqual(['child task', 'continue without provider'])
})
it('wakes a waiting Activation instead of cold-resuming it', async () => {
const releaseGrandchild = Promise.withResolvers<undefined>()
const adapter = new GatedAdapter([
@@ -615,6 +676,48 @@ describe('continuable durability and teardown', () => {
.rejects.toMatchObject({ code: 'DRAINING' })
})
it('rejects an initial prompt when drain starts after materialization', async () => {
const { ctx, parent } = await setup([])
const drains: Promise<void>[] = []
const accepted: MessageId[] = []
ctx.on('subagent/start', () => { drains.push(ctx.subagents.drainContinuable()) })
ctx.on('agent/inbox/enqueue', (_agent, item) => { accepted.push(item.message.id) })
await expect(ctx.subagents.startContinuable(startSpec(parent)))
.rejects.toMatchObject({ code: 'DRAINING' })
await Promise.all(drains)
expect(accepted).toEqual([])
expect(ctx.agents.list()).toEqual([parent])
})
it('admits a live follow-up before a later drain can begin disposal', async () => {
const hold = Promise.withResolvers<undefined>()
const adapter = new GatedAdapter([{ chunks: textResponse('working'), gate: hold.promise }])
const { ctx, parent } = await setupWith(adapter)
const started = await ctx.subagents.startContinuable(startSpec(parent))
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
const child = ctx.agents.get(started.childId)!
const order: string[] = []
child.ctx.on('agent/inbox/enqueue', (_agent, accepted) => {
if (accepted.message.content.some(block => block.type === 'text' && block.text === 'before drain')) {
order.push('enqueue')
}
})
child.ctx.on('agent/cancel-requested', () => { order.push('cancel') })
const delivery = followup(ctx, parent, started.childId, message('before drain'))
// Let the child-lock operation reach the live admission cutoff. Admission
// and inbox submission must then complete in one synchronous span.
await Promise.resolve()
const drained = ctx.subagents.drainContinuable()
hold.resolve(undefined)
await expect(delivery).resolves.toBeTypeOf('string')
await drained
expect(order).toEqual(['enqueue', 'cancel'])
})
it('has no automatic replay for an accepted but unlogged message', async () => {
const hold = Promise.withResolvers<undefined>()
const adapter = new GatedAdapter([{ chunks: textResponse('first'), gate: hold.promise }])
@@ -744,6 +847,29 @@ describe('continuable review regressions', () => {
expect(ends[0]!.stopReason).toBe('error')
})
it('reports a pre-disposal teardown failure on the terminal edge', async () => {
const hold = Promise.withResolvers<undefined>()
const adapter = new GatedAdapter([{ chunks: textResponse('answer'), gate: hold.promise }])
const { ctx, parent } = await setupWith(adapter)
const ends: SubagentRunEndInfo[] = []
ctx.on('subagent/end', info => void ends.push(info))
const started = await ctx.subagents.startContinuable(startSpec(parent))
const manager = (ctx.subagents as unknown as {
continuations: {
activations: Map<SessionId, { observer: { capture: (child: Agent) => void } }>
}
}).continuations
const activation = manager.activations.get(started.childId)!
activation.observer.capture = () => { throw new Error('capture failed') }
const drained = ctx.subagents.drainContinuable()
hold.resolve(undefined)
await expect(drained).rejects.toMatchObject({ code: 'ACTIVATION_TEARDOWN_FAILED' })
await vi.waitFor(() => { expect(ends).toHaveLength(1) })
expect(ends[0]!.stopReason).toBe('error')
})
it('cancels a running turn before the final durability checkpoint', async () => {
const hold = Promise.withResolvers<undefined>()
const adapter = new GatedAdapter([{ chunks: textResponse('slow'), gate: hold.promise }])
@@ -797,8 +923,8 @@ describe('continuable review regressions', () => {
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
const child = ctx.agents.get(started.childId)!
// Cancel from the synchronous enqueue observer: the discard fires before
// `followup()` returns, so the id is discarded before it can be recorded.
// Cancel from the synchronous enqueue observer: the discard fires after the
// id is recorded but before `followup()` returns.
const off = child.ctx.on('agent/inbox/enqueue', (_agent, accepted) => {
if (accepted.message.content.some(block => block.type === 'text' && block.text === 'doomed')) {
child.cancel({ kind: 'user' })
@@ -815,6 +941,35 @@ describe('continuable review regressions', () => {
expect(hasUserText(loaded.events, 'doomed')).toBe(false)
})
it('releases older ids discarded during a later admission window', async () => {
const releaseFirst = Promise.withResolvers<undefined>()
const adapter = new GatedAdapter([{ chunks: textResponse('working'), gate: releaseFirst.promise }])
const { ctx, parent } = await setupWith(adapter)
const started = await ctx.subagents.startContinuable(startSpec(parent))
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
const child = ctx.agents.get(started.childId)!
const manager = (ctx.subagents as unknown as {
continuations: {
activations: Map<SessionId, { accepted: Set<MessageId> }>
}
}).continuations
const activation = manager.activations.get(started.childId)!
await followup(ctx, parent, started.childId, message('queued'))
expect(activation.accepted.size).toBe(1)
const off = child.ctx.on('agent/inbox/enqueue', (_agent, accepted) => {
if (accepted.message.content.some(block => block.type === 'text' && block.text === 'doomed')) {
child.cancel({ kind: 'user' })
}
})
await followup(ctx, parent, started.childId, message('doomed'))
off()
expect(activation.accepted.size).toBe(0)
releaseFirst.resolve(undefined)
await waitNoActivation(ctx, started.childId)
})
it('reports completed when no ordinary turn closed', async () => {
const { ctx, parent } = await setup([])
const ends: SubagentRunEndInfo[] = []

View File

@@ -68,10 +68,10 @@ describe('subagent invariants', () => {
it('rejects malformed and unpaired run transitions', async () => {
const ctx = await setup()
expect(() => { emitRun(ctx, 'subagent/start', start()) }).toThrow(/inactive provider/)
ctx.emit('subagent/provider-added', provider('mock'))
expect(() => { emitRun(ctx, 'subagent/start', start({ provider: '' })) })
.toThrow(/provider, runId, and child id must be non-empty/)
expect(() => { emitRun(ctx, 'subagent/start', start({ runId: SubagentRunId('') })) })
.toThrow(/runId and child id must be non-empty/)
.toThrow(/provider, runId, and child id must be non-empty/)
emitRun(ctx, 'subagent/start', start())
expect(() => { emitRun(ctx, 'subagent/start', start()) }).toThrow(/repeated run id/)
expect(() => { emitRun(ctx, 'subagent/end', end({ runId: SubagentRunId('missing') })) })
@@ -79,4 +79,14 @@ describe('subagent invariants', () => {
expect(() => { emitRun(ctx, 'subagent/end', end({ id: SessionId('other') })) })
.toThrow(/identity diverges/)
})
it('accepts historical provider provenance after registration ends', async () => {
const ctx = await setup()
const historical = provider('historical')
ctx.emit('subagent/provider-added', historical)
ctx.emit('subagent/provider-removed', historical.name)
emitRun(ctx, 'subagent/start', start({ provider: historical.name }))
emitRun(ctx, 'subagent/end', end({ provider: historical.name }))
})
})

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/subagent/tool-subagent-control/README.md
README.md: b62870217e0eaf57c1cd16204c703aada694d4f2
README.zh.md: 24a4b7b69a2f95533e4f0b963156fce0aad46bf4
README.md: 5023862cba39769248a9f6cbe935d6397df39266
README.zh.md: a5812704609edd38aedc344b4c64044fbf32c8a8

View File

@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
The optional, globally named `send_message` tool: a thin adapter over `ctx.subagents.followup()`. Provider-bound `@deepseek-ai/dsh-tool-subagent` instances register distinct delegation tools per transport; this separately loaded package registers one shared follow-up tool, so multiple delegation tools never register duplicate global controls. Its presence does not determine whether a delegation tool starts continuable work.
The tool performs no lifecycle routing — residency and cold resume belong to the subagent service. It supplies exact live parent authority (`{ kind: 'parent', agent }`) from `exec.agent` and attributes every message as durable provenance `{ kind: 'coordinator', senderSessionId: parent.id }`, which the service retains but never treats as authority. Every message becomes the subagent's next FIFO turn through `Agent.followup()`: if the child is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. The tool forwards its execution signal, which owns admission only until inbox acceptance; once the child accepts the message the accepted turn cannot be cancelled through this tool. The child does not reply to the sender — its transcript by that id is the source of what it did. A delivery failure becomes an errored tool result stating the message was not delivered.
The tool performs no lifecycle routing — residency and cold resume belong to the subagent service. It passes `exec.agent` as the exact live parent that authorizes delivery and attributes every message as durable provenance `{ kind: 'coordinator', senderSessionId: parent.id }`, which the service retains but never treats as authority. Every message becomes the subagent's next FIFO turn through `Agent.followup()`: if the child is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. The tool forwards its execution signal, which owns admission only until inbox acceptance; once the child accepts the message the accepted turn cannot be cancelled through this tool. The child does not reply to the sender — its transcript by that id is the source of what it did. A delivery failure becomes an errored tool result stating the message was not delivered.
## Model Experience

View File

@@ -4,7 +4,7 @@
可选的全局具名 `send_message` 工具:`ctx.subagents.followup()` 之上的轻量适配器。绑定提供方的 `@deepseek-ai/dsh-tool-subagent` 实例会为每种传输注册不同的委派工具这个单独加载的包package只注册一个共享后续操作工具因此多个委派工具绝不会重复注册全局控制工具。是否加载本工具不会决定委派工具是否启动可继续工作。
本工具不执行生命周期路由——驻留与冷恢复归 subagent 服务所有。它 `exec.agent` 提供准确实时父级权限(`{ kind: 'parent', agent }`,并把每条消息的来源标记为持久化来源 `{ kind: 'coordinator', senderSessionId: parent.id }`;服务会保留该来源,但绝不将其视为权限。每条消息都会通过 `Agent.followup()` 成为子 agent智能体的下一个 FIFO 轮次:如果子 agent 仍在工作,该消息会等待其当前轮次结束,因此无法重定向已经在进行的工作。本工具会转发其执行信号,该信号只在 inbox 接受之前掌管准入;一旦子 agent 接受消息,已接受的轮次便无法再通过本工具取消。子 agent 不会回复发送方——通过该 id 查看其 transcript 即是其所做工作的来源。投递失败会变为出错的工具结果,并明确说明消息未送达。
本工具不执行生命周期路由——驻留与冷恢复归 subagent 服务所有。它 `exec.agent` 作为授权投递的准确实时父级传入,并把每条消息的来源标记为持久化来源 `{ kind: 'coordinator', senderSessionId: parent.id }`;服务会保留该来源,但绝不将其视为权限。每条消息都会通过 `Agent.followup()` 成为子 agent智能体的下一个 FIFO 轮次:如果子 agent 仍在工作,该消息会等待其当前轮次结束,因此无法重定向已经在进行的工作。本工具会转发其执行信号,该信号只在 inbox 接受之前掌管准入;一旦子 agent 接受消息,已接受的轮次便无法再通过本工具取消。子 agent 不会回复发送方——通过该 id 查看其 transcript 即是其所做工作的来源。投递失败会变为出错的工具结果,并明确说明消息未送达。
## 模型体验