fix(subagent): persist descriptor before admission

This commit is contained in:
Dudu-0223
2026-07-27 17:54:36 +08:00
committed by imccyu
parent 52002791a4
commit fea31a012d
10 changed files with 49 additions and 27 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-inprocess/README.md
README.md: afc92cf4f38830c22a2de401620e0223e7bf62d1
README.zh.md: dcf7d343901145f63758bfce0f85fa70691cb14e
README.md: 525760ccc413bb46ca5ea3a37e610a3ff58b8068
README.zh.md: 4d02e2bb89f38e449dfd8bf31a39b79891f6a69e

View File

@@ -12,7 +12,7 @@ The driver follows this sequence:
1. Validate the parent depth and optional absolute `maxDepth`, then derive child depth as parent depth plus one and persist it in the child session header.
2. Call `parent.ctx.agents.create` directly, passing the required request signal into the factory's creation transaction. A continuable request publishes exactly `request.continuation.sessionId` instead of an internally minted id.
3. During that transaction's unpublished setup window, install the requested persona, tool restriction, structured-output runtime, and — for a continuable request — the one-shot `agent/step` contribution that appends the `subagent/descriptor` event after the initial `turn/start` and before the first request, so the descriptor reaches persistence with that turn's flush.
3. During that transaction's unpublished setup window, install the requested persona, tool restriction, structured-output runtime, and — for a continuable request — the prepended one-shot `agent/prompt-submit` contribution. It appends the `subagent/descriptor` event before downstream prompt admission can block or throw; allowed admission opens the initial turn afterward, while the final required checkpoint persists the descriptor even when no turn opens.
4. Publish the child, retain the returned `AgentHandle`, and drive one task with `child.followup(prompt)` followed by `child.whenIdle()`.
5. For a continuable start or resume, call `child.ctx.sessions.flushRequired(child.session)` again before returning the result. This final confirmation requires an installed durability listener and retries events retained after a failed turn checkpoint; if no listener participates or any listener fails, `result` rejects with `SubagentError.code === 'DURABILITY_FAILED'`, retains the checkpoint failure as `cause`, and names the resumability risk in its message. Activation cancellation during this await owns the unpublished result even when the completed turn was already recorded or the checkpoint subsequently fails. Foreground runs keep the loop's best-effort checkpoint behavior.
6. Read the child's own last assistant message and latest message-triggered turn reason, excluding any fork seed and later plugin-owned between-turn records.

View File

@@ -12,7 +12,7 @@
1. 校验父 agent 深度和可选的绝对 `maxDepth`,然后把子 agent 深度推导为父 agent 深度加一,并将其持久化到子 agent 会话 header。
2. 直接调用 `parent.ctx.agents.create`,把必需的请求信号传入工厂的创建事务。可继续请求会精确发布 `request.continuation.sessionId`,而不是内部生成的 ID。
3. 在该事务未发布的设置窗口中,安装请求的 persona、工具限制和结构化输出运行时对于可继续请求还会安装一次性的 `agent/step` 贡献,在初始 `turn/start` 之后、首次请求之前追加 `subagent/descriptor` 事件,使描述符随该轮次的 flush 到达持久化层
3. 在该事务未发布的设置窗口中,安装请求的 persona、工具限制和结构化输出运行时对于可继续请求还会前置安装一次性的 `agent/prompt-submit` 贡献。它会在下游 prompt admission 能够阻止请求或抛出异常之前追加 `subagent/descriptor` 事件admission 获准后才会开启初始轮次,即使没有轮次开启,最终的必需检查点仍会持久化该描述符
4. 发布子 agent保留返回的 `AgentHandle`,并通过先调用 `child.followup(prompt)`、再调用 `child.whenIdle()` 来驱动一项任务。
5. 对于可继续启动或恢复,在返回结果前再次调用 `child.ctx.sessions.flushRequired(child.session)`。这次最终确认要求有已安装的持久性监听器参与,并会重试轮次检查点失败后保留的事件;如果没有监听器参与或任一监听器失败,`result` 会以 `SubagentError.code === 'DURABILITY_FAILED'` 拒绝,将检查点失败保留为 `cause`,并在消息中说明恢复风险。即使已记录完成的轮次,或随后检查点失败,等待期间发生的激活取消仍决定尚未发布的结果。前台运行保留循环的尽力检查点行为。
6. 读取子 agent 自身最后一条 assistant 消息,以及由消息触发的最新轮次原因;排除任何 fork 初始内容和后续由插件拥有的轮次间记录。

View File

@@ -85,17 +85,16 @@ function prePublicationAbort(): Error {
/**
* Register the one-shot child-scoped contribution that appends the durable
* `subagent/descriptor` event. `agent/step` is the first serial seam
* inside the child's initial turn, so the append lands after `turn/start` and
* before the first request, and reaches persistence with that turn's flush.
* `subagent/descriptor` event. The prepended `agent/prompt-submit` wrapper
* appends before downstream admission can block or throw. Allowed admission
* opens the initial turn afterward; the final required checkpoint also
* persists the descriptor when no turn opens.
*/
function attachDescriptorAppend(childCtx: Context, descriptor: SubagentDescriptorData): void {
let appended = false
childCtx.on('agent/step', (agent) => {
if (appended) return
appended = true
childCtx.once('agent/prompt-submit', (agent, _message, _signal, next) => {
agent.session.append('subagent/descriptor', descriptor)
})
return next()
}, { prepend: true })
}
/**
@@ -103,7 +102,7 @@ function attachDescriptorAppend(childCtx: Context, descriptor: SubagentDescripto
* already published in the registry; rejection means the agent factory's
* creation transaction and any partially-created child have reached quiescence.
* A `request.continuation` publishes exactly its stable child id and appends
* its descriptor inside the child's initial turn.
* its descriptor before the child's initial prompt admission.
* @param request - the trusted typed start request, including its required signal.
* @param options - the optional fork seed.
* @returns a ready holder-owned run.

View File

@@ -162,7 +162,7 @@ describe('SubagentService.startContinuable', () => {
expect(ctx.agents.get(started.childId)).toBeUndefined()
})
it('publishes the service-allocated child id and appends the turn-enclosed descriptor', async () => {
it('publishes the service-allocated child id and appends the pre-turn descriptor', async () => {
const { ctx, parent } = await setup([textResponse('answer')])
const seen: SessionEvent[] = []
ctx.on('session/event', (session, event) => {
@@ -174,7 +174,7 @@ describe('SubagentService.startContinuable', () => {
const descriptorIndex = seen.findIndex(event => event.type === 'subagent/descriptor')
const turnStartIndex = seen.findIndex(event => event.type === 'turn/start')
const firstAssistant = seen.findIndex(event => event.type === 'assistant/message')
expect(descriptorIndex).toBeGreaterThan(turnStartIndex)
expect(descriptorIndex).toBeLessThan(turnStartIndex)
expect(descriptorIndex).toBeLessThan(firstAssistant)
const descriptor = seen[descriptorIndex] as SessionEvent<'subagent/descriptor'>
expect(descriptor.data).toEqual({
@@ -193,6 +193,29 @@ describe('SubagentService.startContinuable', () => {
expect(loaded.events.some(event => event.type === 'subagent/descriptor')).toBe(true)
})
it.each(['block', 'throw'] as const)(
'persists the descriptor before initial prompt admission can $0',
async (outcome) => {
const { ctx, parent, adapter } = await setup([])
ctx.on('agent/prompt-submit', async (subject, _message, _signal, next) => {
if (subject === parent) return next()
if (outcome === 'block') return { kind: 'block', reason: 'blocked by policy' }
throw new Error('prompt admission failed')
})
const started = ctx.subagents.startContinuable(startSpec(parent))
const snapshot = await waitTerminal(ctx, started.taskId, parent)
expect(snapshot.status).toBe('failed')
expect(adapter.requests).toEqual([])
const loaded = await ctx.sessionPersistence.load(started.childId)
const descriptorIndexes = loaded.events.flatMap((event, index) =>
event.type === 'subagent/descriptor' ? [index] : [])
expect(descriptorIndexes).toHaveLength(1)
expect(loaded.events.some(event => event.type === 'turn/start')).toBe(false)
},
)
it('rejects synchronously with no Task when persistence is not configured', async () => {
const { ctx, parent } = await setup([textResponse('unused')], { persistence: false })
expect(() => ctx.subagents.startContinuable(startSpec(parent)))