fix(subagent): address codex review round 3
- Make host-user authority unforgeable. `{ kind: 'user' }` was a bare
discriminant, so any plugin holding `ctx.subagents` — including
model-generated cordis_mount code, which the advanced ACP composition ships
alongside continuable subagents — could construct it and skip the
direct-parent check for any known child id. It now carries an opaque grant
that only SubagentService.userAuthority() mints, which composition hands to
trusted host adapters; a model-facing tool uses parent authority from its own
execution context.
- Reconcile a delivery discarded inside its own admission window. An enqueue
listener that cancels fires the discard before followup() returns, so the
discard listener could not clear an id it had not seen; submit() retained it
and residency stayed `running` until an explicit drain.
- Recheck the caller signal after materialization. An abort landing between
publication and inbox acceptance still submitted the prompt and returned both
ids; it now rolls the child back.
- Stop promising the model transcript access that no shipped continuable config
mounts. The tools now state only that a background child does not report back.
- Restate the implemented note as shipped state rather than a proposal, so it
works as current authority.
This commit is contained in:
@@ -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: fc1eecb7d22c45377d5525ef0247bcf369a441a8
|
||||
README.zh.md: 762a027324bc40f159129c3cd4a438d2265fa32b
|
||||
README.md: 6a8016dc71d928c1770cc0769f99d2cb53c6b035
|
||||
README.zh.md: 53f553bd2747bebac0f2d42ac80ad8b6eb660c45
|
||||
|
||||
@@ -31,12 +31,13 @@ Multiple providers may coexist under different names. This lets a deployment exp
|
||||
| `start(name, request)` | Validate an ordinary caller request, then await the provider until a real one-shot child is ready. Fulfillment returns a holder-owned `SubagentRun`; rejection means the provider has already cleaned every partial startup resource. Continuable children never enter through this operation. |
|
||||
| `startContinuable(spec)` | Establish one durable continuable child and deliver its initial prompt. Resolves with `{ childId, messageId }` when the child's inbox accepts that prompt, without waiting for the turn to start or for the message to reach the Session log; any earlier failure rejects with no ids and rolls the child back entirely. Requires `ctx.agents`, session persistence, and a provider with the `prepareContinuable` capability. |
|
||||
| `followup(authority, childId, content, { source, signal })` | Deliver one later message to a continuable child as its next FIFO turn, matching `Agent.followup()` terminology, and return the accepted `AgentMessageId`. A resident child's inbox accepts it directly (waking a `waiting` Activation); an absent one cold-resumes from its persisted Session. Requires `ctx.agents`; cold resume also requires session persistence. |
|
||||
| `userAuthority()` | Mint the host-user authority a trusted adapter passes to `followup()`. Composition hands this only to a host carrying real human interaction; a model-facing tool uses its own `{ kind: 'parent', agent }` instead. |
|
||||
| `activationState(childId)` | Read one durable child's live residency state (`running`, `waiting`, or `settled`), or `undefined` when no Activation is live. |
|
||||
| `drainContinuable()` | Close continuable admission synchronously, then dispose every live Activation forest child-first. A host calls this before disposing top-level agents so no descendant outlives the runtime that owns its teardown. An aggregate error surfaces after every branch settles when any failed. |
|
||||
|
||||
`SubagentStartRequest.signal` is required and is the canonical cancellation channel for a one-shot `start`. An abort before publication makes `start()` reject after rollback; an abort after publication cancels the live child. The request may also select a model, require structured output, cap delegation depth, restrict child tools, or set a child persona. For a continuable start or follow-up, the caller signal owns lookup, materialization, and admission only until inbox acceptance; afterward the manager owns the Activation independently, so later caller cancellation neither cancels the accepted turn nor disposes the child.
|
||||
|
||||
Authority for continuable operations comes from a trusted host interaction or an exact live Agent tool context: `SubagentAuthority` is `{ kind: 'parent', agent }` or `{ kind: 'user' }`. The `source` on a follow-up is durable provenance retained on the delivered message and grants no authority. Parent authority requires the exact live direct parent recorded in the child's durable header; user authority may continue any child, and may cold-resume it without loading its historical parent.
|
||||
Authority for continuable operations comes from a trusted host interaction or an exact live Agent tool context: `SubagentAuthority` is `{ kind: 'parent', agent }` or `{ kind: 'user', grant }`, whose grant only `userAuthority()` mints so the discriminant alone cannot claim it. The `source` on a follow-up is durable provenance retained on the delivered message and grants no authority. Parent authority requires the exact live direct parent recorded in the child's durable header; user authority may continue any child, and may cold-resume it without loading its historical parent.
|
||||
|
||||
Same-process requests, descriptors, results, and event payloads are trusted typed values borrowed as immutable. The service does not clone or freeze them; serialization and hostile-input validation belong at actual process, worker, persistence, and model boundaries.
|
||||
|
||||
|
||||
@@ -31,12 +31,13 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委
|
||||
| `start(name, request)` | 校验普通调用方请求,然后等待提供方,直到真实的一次性子 agent 就绪。兑现时返回由持有方拥有的 `SubagentRun`;拒绝表示提供方已清理所有局部启动资源。可继续子 agent 绝不通过此操作进入。 |
|
||||
| `startContinuable(spec)` | 建立一个持久化可继续子 agent,并投递其初始提示词。子 agent 的 inbox 接受该提示词时,兑现为 `{ childId, messageId }`,无需等待轮次开始或消息写入 Session 日志;此前任何失败都会以无 id 拒绝,并完全回滚该子 agent。要求 `ctx.agents`、会话持久化以及具备 `prepareContinuable` 能力的提供方。 |
|
||||
| `followup(authority, childId, content, { source, signal })` | 将一条后续消息作为可继续子 agent 的下一个 FIFO 轮次投递,术语与 `Agent.followup()` 一致,并返回被接受的 `AgentMessageId`。驻留中的子 agent 由其 inbox 直接接受(唤醒处于 `waiting` 的 Activation);不驻留的则从其持久化 Session 冷恢复。要求 `ctx.agents`;冷恢复还要求会话持久化。 |
|
||||
| `userAuthority()` | 铸造可信 host 适配器传给 `followup()` 的 host 用户权限。组合装配仅将其交给承载真实人类交互的 host;面向模型的工具改用自身执行上下文的 `{ kind: 'parent', agent }`。 |
|
||||
| `activationState(childId)` | 读取某个持久化子 agent 的实时驻留状态(`running`、`waiting` 或 `settled`);无实时 Activation 时返回 `undefined`。 |
|
||||
| `drainContinuable()` | 同步关闭可继续准入,然后以子先于父的顺序 dispose 每一个实时 Activation 森林。host 会在 dispose 顶层 agent 之前调用它,使任何后代都不会比拥有其拆卸职责的运行时存活更久。任一分支失败时,会在所有分支结算后抛出聚合错误。 |
|
||||
|
||||
`SubagentStartRequest.signal` 是必填项,也是一次性 `start` 的规范取消通道。发布前中止会使 `start()` 在回滚后拒绝;发布后中止会取消实时子 agent。请求还可以选择模型、要求结构化输出、限制委派深度、约束子 agent 工具或设置子 agent persona。对于可继续启动或后续操作,调用方信号只在 inbox 接受之前掌管查找、物化和准入;此后由管理器独立拥有 Activation,因此调用方后续取消既不会取消已接受的轮次,也不会 dispose 子 agent。
|
||||
|
||||
可继续操作的权限来自可信的 host 交互或准确的实时 Agent 工具上下文:`SubagentAuthority` 为 `{ kind: 'parent', agent }` 或 `{ kind: 'user' }`。后续操作上的 `source` 是保留在所投递消息上的持久化来源,不授予任何权限。父级权限要求准确匹配子 agent 持久化 header 中记录的实时直接父级;用户权限可以继续任何子 agent,并且可以在不加载其历史父级的情况下将其冷恢复。
|
||||
可继续操作的权限来自可信的 host 交互或准确的实时 Agent 工具上下文:`SubagentAuthority` 为 `{ kind: 'parent', agent }` 或 `{ kind: 'user', grant }`——其 grant 仅由 `userAuthority()` 铸造,因此仅凭判别式无法声明该权限。后续操作上的 `source` 是保留在所投递消息上的持久化来源,不授予任何权限。父级权限要求准确匹配子 agent 持久化 header 中记录的实时直接父级;用户权限可以继续任何子 agent,并且可以在不加载其历史父级的情况下将其冷恢复。
|
||||
|
||||
同进程请求、描述符、结果和事件 payload 都是以不可变方式借用的可信类型值。服务不会克隆或冻结它们;序列化和不可信输入校验属于真实的进程、worker、持久化和模型边界。
|
||||
|
||||
|
||||
@@ -61,8 +61,20 @@ declare module '@deepseek-ai/dsh-llm' {
|
||||
export type SubagentAuthority =
|
||||
/** The exact live parent Agent whose tool context is making the call. */
|
||||
| { readonly kind: 'parent'; readonly agent: Agent }
|
||||
/** A trusted host adapter acting for the human user. */
|
||||
| { readonly kind: 'user' }
|
||||
/**
|
||||
* A trusted host adapter acting for the human user. The `grant` must be the
|
||||
* exact token {@link SubagentService.userAuthority} minted, so a discriminant
|
||||
* alone cannot claim this authority — any plugin holding `ctx.subagents`,
|
||||
* including model-generated mount code, could otherwise forge it and bypass
|
||||
* the direct-parent check.
|
||||
*/
|
||||
| { readonly kind: 'user'; readonly grant: UserAuthorityGrant }
|
||||
|
||||
/**
|
||||
* Opaque proof that a caller obtained user authority from the service rather
|
||||
* than constructing it. Only {@link SubagentService.userAuthority} mints one.
|
||||
*/
|
||||
export type UserAuthorityGrant = { readonly __brand: 'SubagentUserAuthority' }
|
||||
|
||||
/** What a caller asks for when starting a continuable background child. */
|
||||
export interface ContinuableStartSpec {
|
||||
@@ -245,6 +257,8 @@ export class SubagentContinuationManager {
|
||||
constructor(
|
||||
private readonly ctx: Context,
|
||||
private readonly host: ContinuationHost,
|
||||
/** The single token that proves host-user authority for this manager. */
|
||||
private readonly userGrant: UserAuthorityGrant,
|
||||
) {
|
||||
// Ordinary Cordis owner effects unwind in reverse registration order, which
|
||||
// cannot express the dynamic child graph. Register the private scope's
|
||||
@@ -325,6 +339,10 @@ 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' }, { kind: 'parent', agent: parent })
|
||||
})
|
||||
return { childId, messageId }
|
||||
@@ -494,9 +512,25 @@ 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, authority)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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()
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or resume the child Agent through the private activation-owner
|
||||
* scope, install the handle in a fresh Activation, and register ownership on
|
||||
@@ -686,7 +720,17 @@ export class SubagentContinuationManager {
|
||||
childId: SessionId,
|
||||
parentSession: SessionId | undefined,
|
||||
): void {
|
||||
if (authority.kind === 'user') return
|
||||
if (authority.kind === 'user') {
|
||||
// Identity, not shape: a forged discriminant must not skip the
|
||||
// direct-parent check for an arbitrary known child id.
|
||||
if (authority.grant !== this.userGrant) {
|
||||
throw new SubagentError(
|
||||
`subagent "${childId}" delivery presented an invalid user-authority grant`,
|
||||
'UNAUTHORIZED',
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
const parent = authority.agent
|
||||
if (this.ctx.agents.get(parent.id) !== parent) {
|
||||
throw new SubagentError(
|
||||
|
||||
@@ -54,6 +54,7 @@ import SubagentContinuationManager from './continuation.ts'
|
||||
import type {
|
||||
ActivationObserver,
|
||||
ActivationState,
|
||||
UserAuthorityGrant,
|
||||
ContinuableStart,
|
||||
ContinuableStartSpec,
|
||||
SubagentAuthority,
|
||||
@@ -94,6 +95,7 @@ export type { ChildComposition } from './child-agent.ts'
|
||||
export type {
|
||||
ActivationObserver,
|
||||
ActivationState,
|
||||
UserAuthorityGrant,
|
||||
ContinuableStart,
|
||||
ContinuableStartSpec,
|
||||
CoordinatorMessageSource,
|
||||
@@ -174,6 +176,15 @@ export interface SubagentRunEndInfo {
|
||||
export class SubagentService extends Service {
|
||||
private providers = new Map<string, SubagentProvider>()
|
||||
private continuations: SubagentContinuationManager | undefined
|
||||
/**
|
||||
* The process-local proof of host-user authority. Minted here so the value is
|
||||
* unguessable and unforgeable: a caller must obtain it from
|
||||
* {@link userAuthority}, which composition hands only to trusted host
|
||||
* adapters.
|
||||
*/
|
||||
private readonly userGrant = Object.freeze({
|
||||
__brand: 'SubagentUserAuthority',
|
||||
}) as UserAuthorityGrant
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'subagents')
|
||||
@@ -181,7 +192,7 @@ export class SubagentService extends Service {
|
||||
const manager = new SubagentContinuationManager(childCtx, {
|
||||
prepareContinuable: (name, request) => this.prepareContinuable(name, request),
|
||||
observeActivation: (provider, childId, parent) => this.observeActivation(provider, childId, parent),
|
||||
})
|
||||
}, this.userGrant)
|
||||
this.continuations = manager
|
||||
childCtx.effect(() => () => {
|
||||
/* v8 ignore else -- one injected binding owns the slot until its fiber disposes. */
|
||||
@@ -227,6 +238,17 @@ export class SubagentService extends Service {
|
||||
return this.requireContinuations().followup(authority, childId, content, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* Host-user authority for continuable operations, which may continue any
|
||||
* durable child without its parent. A composition passes this only to a
|
||||
* trusted host adapter carrying real human interaction; a model-facing tool
|
||||
* uses `{ kind: 'parent', agent }` from its own execution context instead.
|
||||
* @returns the authority a host adapter supplies to {@link followup}.
|
||||
*/
|
||||
userAuthority(): SubagentAuthority {
|
||||
return { kind: 'user', grant: this.userGrant }
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one durable child's live residency state.
|
||||
* @param childId - durable child session id.
|
||||
|
||||
@@ -213,6 +213,22 @@ describe('SubagentService.startContinuable', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('rolls the child back when the signal aborts between publication and acceptance', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('unused')])
|
||||
const controller = new AbortController()
|
||||
// `subagent/start` fires once the epoch is resident, before the prompt is
|
||||
// submitted, so cancelling here lands squarely in the handoff window.
|
||||
ctx.on('subagent/start', () => { controller.abort('caller gave up') })
|
||||
|
||||
await expect(ctx.subagents.startContinuable(startSpec(parent, 'spawn', controller.signal)))
|
||||
.rejects.toThrow()
|
||||
|
||||
// No resident child and no queued turn survive the abort.
|
||||
await vi.waitFor(() => {
|
||||
expect(ctx.agents.list().map(agent => agent.id)).toEqual([SessionId('parent')])
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a continuable child that would exceed the configured depth cap', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
await expect(ctx.subagents.startContinuable({
|
||||
@@ -287,7 +303,7 @@ describe('SubagentService.startContinuable', () => {
|
||||
await fresh.plugin(AgentLoop, { agents: [] })
|
||||
await fresh.plugin(SubagentService)
|
||||
await fresh.plugin(SubagentSpawn, { providerName: 'spawn' })
|
||||
await followup(fresh, { kind: 'user' }, started.childId, message('resume routeless'))
|
||||
await followup(fresh, fresh.subagents.userAuthority(), started.childId, message('resume routeless'))
|
||||
|
||||
const resumed = await vi.waitFor(() => {
|
||||
const found = fresh.agents.get(started.childId)
|
||||
@@ -337,7 +353,7 @@ describe('SubagentService.startContinuable', () => {
|
||||
expect(descriptor?.data).toMatchObject({ persona: 'You are scoped.' })
|
||||
|
||||
// Cold resume reconstructs the declared composition from that descriptor.
|
||||
await followup(ctx, { kind: 'user' }, started.childId, message('resume it'))
|
||||
await followup(ctx, ctx.subagents.userAuthority(), started.childId, message('resume it'))
|
||||
await waitNoActivation(ctx, started.childId)
|
||||
const resumed = await ctx.sessionPersistence.load(started.childId)
|
||||
expect(hasUserText(resumed.events, 'resume it')).toBe(true)
|
||||
@@ -360,7 +376,7 @@ describe('SubagentService.followup residency routing', () => {
|
||||
|
||||
// Both origins queue behind the open turn, in call order.
|
||||
const parentMessage = await followup(ctx, { kind: 'parent', agent: parent }, started.childId, message('from parent'))
|
||||
const userMessage = await followup(ctx, { kind: 'user' }, started.childId, message('from user'))
|
||||
const userMessage = await followup(ctx, ctx.subagents.userAuthority(), started.childId, message('from user'))
|
||||
expect(parentMessage).not.toBe(userMessage)
|
||||
// Still the same Activation: no second child Agent was created.
|
||||
expect(ctx.agents.get(started.childId)).toBe(child)
|
||||
@@ -376,7 +392,7 @@ describe('SubagentService.followup residency routing', () => {
|
||||
const started = await ctx.subagents.startContinuable(startSpec(parent))
|
||||
await waitNoActivation(ctx, started.childId)
|
||||
|
||||
const messageId = await followup(ctx, { kind: 'user' }, started.childId, message('continue please'))
|
||||
const messageId = await followup(ctx, ctx.subagents.userAuthority(), started.childId, message('continue please'))
|
||||
expect(messageId).toBeTypeOf('string')
|
||||
await waitNoActivation(ctx, started.childId)
|
||||
|
||||
@@ -410,7 +426,7 @@ describe('SubagentService.followup residency routing', () => {
|
||||
// Waiting retains the handle: the same Agent is still live.
|
||||
expect(ctx.agents.get(started.childId)).toBe(child)
|
||||
|
||||
await followup(ctx, { kind: 'user' }, started.childId, message('while waiting'))
|
||||
await followup(ctx, ctx.subagents.userAuthority(), started.childId, message('while waiting'))
|
||||
// Woken back to running on the SAME Activation.
|
||||
expect(ctx.agents.get(started.childId)).toBe(child)
|
||||
|
||||
@@ -421,6 +437,23 @@ describe('SubagentService.followup residency routing', () => {
|
||||
expect(userTexts(loaded.events)).toEqual(['child task', 'while waiting'])
|
||||
})
|
||||
|
||||
it('rejects a forged user-authority grant', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('first')])
|
||||
const started = await ctx.subagents.startContinuable(startSpec(parent))
|
||||
await waitNoActivation(ctx, started.childId)
|
||||
|
||||
// Any plugin holding `ctx.subagents` can write this shape, so shape alone
|
||||
// must not skip the direct-parent check for an arbitrary known child id.
|
||||
const forged = { kind: 'user', grant: { __brand: 'SubagentUserAuthority' } } as unknown as SubagentAuthority
|
||||
await expect(followup(ctx, forged, started.childId, message('not really the user')))
|
||||
.rejects.toMatchObject({ code: 'UNAUTHORIZED' })
|
||||
|
||||
// The service-minted grant is accepted.
|
||||
await expect(followup(ctx, ctx.subagents.userAuthority(), started.childId, message('really the user')))
|
||||
.resolves.toBeTypeOf('string')
|
||||
await waitNoActivation(ctx, started.childId)
|
||||
})
|
||||
|
||||
it('rejects a parent that is not the durable direct parent', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('first')])
|
||||
const started = await ctx.subagents.startContinuable(startSpec(parent))
|
||||
@@ -447,7 +480,7 @@ describe('SubagentService.followup residency routing', () => {
|
||||
fresh.llm.registerAdapter(['mock'], new MockAdapter([textResponse('resumed cold')]))
|
||||
expect(fresh.agents.get(SessionId('parent'))).toBeUndefined()
|
||||
|
||||
await followup(fresh, { kind: 'user' }, started.childId, message('user continues'))
|
||||
await followup(fresh, fresh.subagents.userAuthority(), started.childId, message('user continues'))
|
||||
await waitNoActivation(fresh, started.childId)
|
||||
|
||||
const loaded = await fresh.sessionPersistence.load(started.childId)
|
||||
@@ -469,13 +502,13 @@ describe('SubagentService.followup residency routing', () => {
|
||||
const oneShotId = run.id
|
||||
await run.dispose()
|
||||
|
||||
await expect(followup(ctx, { kind: 'user' }, oneShotId, message('continue')))
|
||||
await expect(followup(ctx, ctx.subagents.userAuthority(), oneShotId, message('continue')))
|
||||
.rejects.toThrow(/no supported continuation state/)
|
||||
})
|
||||
|
||||
it('reports an unknown child id as unavailable', async () => {
|
||||
const { ctx } = await setup([])
|
||||
await expect(followup(ctx, { kind: 'user' }, SessionId('missing'), message('hello')))
|
||||
await expect(followup(ctx, ctx.subagents.userAuthority(), SessionId('missing'), message('hello')))
|
||||
.rejects.toMatchObject({ code: 'NOT_RESUMABLE' })
|
||||
})
|
||||
|
||||
@@ -491,7 +524,7 @@ describe('SubagentService.followup residency routing', () => {
|
||||
// exactly one side wins the cutoff. A delivery that loses awaits release and
|
||||
// cold-resumes rather than reaching a handle being torn down.
|
||||
const delivery = child.whenIdle().then(() =>
|
||||
followup(ctx, { kind: 'user' }, started.childId, message('raced')))
|
||||
followup(ctx, ctx.subagents.userAuthority(), started.childId, message('raced')))
|
||||
|
||||
await expect(delivery).resolves.toBeTypeOf('string')
|
||||
await waitNoActivation(ctx, started.childId)
|
||||
@@ -619,7 +652,7 @@ describe('continuable durability and teardown', () => {
|
||||
|
||||
await expect(ctx.subagents.startContinuable(startSpec(parent)))
|
||||
.rejects.toMatchObject({ code: 'DRAINING' })
|
||||
await expect(followup(ctx, { kind: 'user' }, started.childId, message('too late')))
|
||||
await expect(followup(ctx, ctx.subagents.userAuthority(), started.childId, message('too late')))
|
||||
.rejects.toMatchObject({ code: 'DRAINING' })
|
||||
})
|
||||
|
||||
@@ -630,7 +663,7 @@ describe('continuable durability and teardown', () => {
|
||||
const started = await ctx.subagents.startContinuable(startSpec(parent))
|
||||
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
|
||||
// Accepted into the inbox, but this queued turn never opens.
|
||||
await followup(ctx, { kind: 'user' }, started.childId, message('never logged'))
|
||||
await followup(ctx, ctx.subagents.userAuthority(), started.childId, message('never logged'))
|
||||
|
||||
const drained = ctx.subagents.drainContinuable()
|
||||
hold.resolve(undefined)
|
||||
@@ -674,7 +707,7 @@ describe('continuable review regressions', () => {
|
||||
|
||||
const controller = new AbortController()
|
||||
controller.abort('caller gave up')
|
||||
await expect(followup(ctx, { kind: 'user' }, started.childId, message('cancelled'), controller.signal))
|
||||
await expect(followup(ctx, ctx.subagents.userAuthority(), started.childId, message('cancelled'), controller.signal))
|
||||
.rejects.toThrow()
|
||||
|
||||
// Nothing was enqueued, so no later turn can carry it.
|
||||
@@ -699,7 +732,7 @@ describe('continuable review regressions', () => {
|
||||
|
||||
// A cold resume is a new epoch: it must report its OWN answer, never the
|
||||
// previous epoch's, which the replayed transcript still contains.
|
||||
await followup(ctx, { kind: 'user' }, started.childId, message('again'))
|
||||
await followup(ctx, ctx.subagents.userAuthority(), started.childId, message('again'))
|
||||
await waitNoActivation(ctx, started.childId)
|
||||
await vi.waitFor(() => { expect(ends).toHaveLength(2) })
|
||||
expect(ends[1]!.lastAssistantMessage).toEqual([{ type: 'text', text: 'second answer' }])
|
||||
@@ -717,7 +750,7 @@ describe('continuable review regressions', () => {
|
||||
if (subject === parent) return next()
|
||||
return { kind: 'block', reason: 'blocked by policy' }
|
||||
})
|
||||
await followup(ctx, { kind: 'user' }, started.childId, message('again'))
|
||||
await followup(ctx, ctx.subagents.userAuthority(), started.childId, message('again'))
|
||||
await waitNoActivation(ctx, started.childId)
|
||||
|
||||
await vi.waitFor(() => { expect(ends).toHaveLength(1) })
|
||||
@@ -786,7 +819,7 @@ describe('continuable review regressions', () => {
|
||||
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
|
||||
// Queue a turn, then cancel so it is discarded rather than dequeued. The
|
||||
// Activation must still reach settlement instead of waiting on that id.
|
||||
await followup(ctx, { kind: 'user' }, started.childId, message('discarded'))
|
||||
await followup(ctx, ctx.subagents.userAuthority(), started.childId, message('discarded'))
|
||||
|
||||
const drained = ctx.subagents.drainContinuable()
|
||||
hold.resolve(undefined)
|
||||
@@ -797,6 +830,32 @@ describe('continuable review regressions', () => {
|
||||
expect(hasUserText(loaded.events, 'discarded')).toBe(false)
|
||||
})
|
||||
|
||||
it('settles after a delivery discarded inside its own 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)!
|
||||
|
||||
// Cancel from the synchronous enqueue observer: the discard fires before
|
||||
// `followup()` returns, so the id is discarded before it can be recorded.
|
||||
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, ctx.subagents.userAuthority(), started.childId, message('doomed'))
|
||||
off()
|
||||
|
||||
releaseFirst.resolve(undefined)
|
||||
// Retaining the discarded id would pin residency at `running` forever, so
|
||||
// reaching no-Activation without an explicit drain is the assertion.
|
||||
await waitNoActivation(ctx, started.childId)
|
||||
const loaded = await ctx.sessionPersistence.load(started.childId)
|
||||
expect(hasUserText(loaded.events, 'doomed')).toBe(false)
|
||||
})
|
||||
|
||||
it('reports completed when no ordinary turn closed', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
const ends: SubagentRunEndInfo[] = []
|
||||
@@ -832,7 +891,7 @@ describe('continuable review regressions', () => {
|
||||
|
||||
const started = await ctx.subagents.startContinuable(startSpec(parent))
|
||||
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
|
||||
await followup(ctx, { kind: 'user' }, started.childId, message('queued'))
|
||||
await followup(ctx, ctx.subagents.userAuthority(), started.childId, message('queued'))
|
||||
|
||||
expect(states.length).toBeGreaterThan(0)
|
||||
expect(states).not.toContain('settled')
|
||||
@@ -854,7 +913,7 @@ describe('continuable lifecycle observation', () => {
|
||||
await vi.waitFor(() => { expect(ends).toHaveLength(1) })
|
||||
|
||||
// A cold resume is a NEW epoch with its own pair.
|
||||
await followup(ctx, { kind: 'user' }, started.childId, message('again'))
|
||||
await followup(ctx, ctx.subagents.userAuthority(), started.childId, message('again'))
|
||||
await waitNoActivation(ctx, started.childId)
|
||||
await vi.waitFor(() => { expect(ends).toHaveLength(2) })
|
||||
|
||||
@@ -898,7 +957,7 @@ describe('continuable public surface', () => {
|
||||
|
||||
const controller = new AbortController()
|
||||
controller.abort('caller gave up')
|
||||
await expect(followup(ctx, { kind: 'user' }, started.childId, message('aborted'), controller.signal))
|
||||
await expect(followup(ctx, ctx.subagents.userAuthority(), started.childId, message('aborted'), controller.signal))
|
||||
.rejects.toThrow()
|
||||
|
||||
const loaded = await ctx.sessionPersistence.load(started.childId)
|
||||
@@ -916,7 +975,7 @@ describe('continuable public surface', () => {
|
||||
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
|
||||
|
||||
const controller = new AbortController()
|
||||
await followup(ctx, { kind: 'user' }, started.childId, message('survives'), controller.signal)
|
||||
await followup(ctx, ctx.subagents.userAuthority(), started.childId, message('survives'), controller.signal)
|
||||
// After acceptance the manager owns the Activation independently.
|
||||
controller.abort('caller gave up')
|
||||
|
||||
@@ -945,7 +1004,7 @@ describe('continuable errors', () => {
|
||||
}).continuations
|
||||
manager.activations.delete(started.childId)
|
||||
|
||||
await expect(followup(ctx, { kind: 'user' }, started.childId, message('hello')))
|
||||
await expect(followup(ctx, ctx.subagents.userAuthority(), started.childId, message('hello')))
|
||||
.rejects.toThrow(SubagentError)
|
||||
expect(ctx.agents.get(started.childId)).toBe(child)
|
||||
hold.resolve(undefined)
|
||||
@@ -1068,7 +1127,7 @@ describe('continuable errors', () => {
|
||||
.toMatchObject({ agentProvider: 'mock', agentModel: 'child-model' })
|
||||
|
||||
// The resumed Activation runs on the declared route, not the parent's.
|
||||
await followup(ctx, { kind: 'user' }, started.childId, message('again'))
|
||||
await followup(ctx, ctx.subagents.userAuthority(), started.childId, message('again'))
|
||||
await vi.waitFor(() => {
|
||||
expect(ctx.agents.get(started.childId)?.options.model).toBe('child-model')
|
||||
})
|
||||
|
||||
@@ -133,7 +133,7 @@ describe('SubagentService', () => {
|
||||
signal: new AbortController().signal,
|
||||
})).rejects.toMatchObject({ code: 'CONTINUATION_UNAVAILABLE' })
|
||||
await expect(subagents.followup(
|
||||
{ kind: 'user' },
|
||||
subagents.userAuthority(),
|
||||
SessionId('child'),
|
||||
[{ type: 'text', text: 'hello' }],
|
||||
{ source: { kind: 'user' }, signal: new AbortController().signal },
|
||||
|
||||
@@ -26,8 +26,8 @@ export function apply(ctx: Context): void {
|
||||
description:
|
||||
'Send a message to a background subagent by its subagent id, continuing the same conversation. It '
|
||||
+ 'becomes the subagent\'s next turn: if it is still working, the message waits until its current turn '
|
||||
+ 'finishes, so it cannot redirect work already underway. The subagent does not reply to you — read its '
|
||||
+ 'transcript by its id to see what it did. A failure means the message was NOT delivered.',
|
||||
+ 'finishes, so it cannot redirect work already underway. The subagent does not reply to you, so use '
|
||||
+ 'this only to give it more work. A failure means the message was NOT delivered.',
|
||||
parameters: {
|
||||
subagent_id: {
|
||||
type: 'string',
|
||||
|
||||
@@ -207,8 +207,8 @@ export function apply(ctx: Context, config: Config): void {
|
||||
description: wording.description + (backgroundEnabled
|
||||
? continuable
|
||||
? ' Set `run_in_background: true` to start a background subagent that keeps its conversation:'
|
||||
+ ' you receive its subagent id and it works on its own. It does not report back to you, so read'
|
||||
+ ' its transcript by that id, or send it more work with `send_message`.'
|
||||
+ ' you receive its subagent id and it works on its own. It does not report back, so use this'
|
||||
+ ' only for work whose result you do not need returned; `send_message` sends it more work.'
|
||||
: ' Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.'
|
||||
: ''),
|
||||
parameters: {
|
||||
@@ -226,8 +226,8 @@ export function apply(ctx: Context, config: Config): void {
|
||||
run_in_background: {
|
||||
type: 'boolean' as const,
|
||||
description: continuable
|
||||
? 'Run as a background subagent that keeps its conversation and return its subagent id; '
|
||||
+ 'send it more work with send_message.'
|
||||
? 'Run as a background subagent that keeps its conversation and return its subagent id. '
|
||||
+ 'It does not report its result back; send it more work with send_message.'
|
||||
: 'Run as a background task and return its id; collect with task_output or stop with task_kill.',
|
||||
},
|
||||
} : {},
|
||||
|
||||
Reference in New Issue
Block a user