Merge branch 'feat/subagent-report-semantics' into feat/subagent-settlement-delivery
# Conflicts: # examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl # examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl # examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.2.jsonl # examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl # examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl # examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl # examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl # examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl # examples/headless-agent/tests/snapshots/compaction-recovery/session.jsonl # examples/headless-agent/tests/snapshots/pty-tools/session.jsonl
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: 6373f09e44a5bb87679d2afe67321f49c7174fef
|
||||
README.zh.md: a657e6371677caa97f0884d3f741a95b3cc6c437
|
||||
README.md: b31a577b23196523b88bff5c05792e06eb1fdd88
|
||||
README.zh.md: 7d276ce6915f916b01b818f249cc0419c477dd12
|
||||
@@ -44,7 +44,7 @@ Every in-process child is composed by one call, `applyChildComposition(childCtx,
|
||||
|
||||
`childSessionMeta()` records the joined preset id on the child's durable header for the same reason a top-level session records its own: the preset decides the tool schemas and prompt sections the model saw, so a cold read of the child's history has to rebuild that composition rather than the deployment default. It is read from the parent's live scope chain, not from the parent header, because a parent that switched preset while blank runs on the newer composition while its header still names the older one.
|
||||
|
||||
Continuable creation is the optional `SubagentProvider.prepareContinuable?()` method: its presence is the capability check, so the service rejects a configured continuable start on a provider without it, while a provider that has it may still serve ordinary one-shot delegations. The method returns only a detached `ContinuableCreateSpec` (`{ seed? }`) — data, never a capability: it carries no Agent, `AgentHandle`, prompt delivery, result, disposal, or resume operation, because the continuation manager owns identity reservation, composition, Agent creation, prompt delivery, cold resume, ownership, and disposal after preparation. A one-shot `SubagentRun` represents one disposable foreground delegation with one result and no cold-resume operation.
|
||||
Continuable creation is the optional `SubagentProvider.prepareContinuable?()` method: its presence is the capability check, so the service rejects a configured continuable start on a provider without it, while a provider that has it may still serve ordinary one-shot delegations. The method returns only a detached `ContinuableCreateSpec` (`{ seed? }`) — data, never a capability: it carries no Agent, `AgentHandle`, prompt delivery, result, disposal, or resume operation, because the continuation manager owns identity reservation, composition, Agent creation, prompt delivery, cold resume, ownership, and disposal after preparation. A one-shot `SubagentRun` represents one disposable foreground delegation with one result and no cold-resume operation. The service may invoke one provider concurrently for distinct siblings: each start or preparation owns its mutable state and cancellation path, and one operation's failure, result, or cleanup must not settle or release another. A provider may queue its own capacity internally without changing that independence contract.
|
||||
|
||||
## The durable descriptor
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委
|
||||
|
||||
`childSessionMeta()` 把所加入的 preset id 记在子 agent 的持久化 header 上,理由与顶层会话记录自己的那一个相同:preset 决定了模型所见的工具 schema 与提示段,因此冷读子 agent 的历史时必须重建那份组装,而不是部署默认值。该值从父方**活着的** scope 链读取,而不是从父方 header 读取,因为在空白期切换过 preset 的父方运行在更新的那份组装上,而它的 header 仍写着旧的那个。
|
||||
|
||||
可继续创建对应可选的 `SubagentProvider.prepareContinuable?()` 方法:方法是否存在就是能力检查,因此服务会在没有该方法的提供方上拒绝已配置的可继续启动,而具备该方法的提供方仍可服务普通一次性委派。该方法只返回分离的 `ContinuableCreateSpec`(`{ seed? }`)——这是数据,绝非能力:它不携带任何 Agent、`AgentHandle`、提示词投递、结果、dispose 或恢复操作,因为准备之后,继续执行管理器拥有身份预留、组合、Agent 创建、提示词投递、冷恢复、所有权和 dispose。一次性 `SubagentRun` 表示一次可 dispose 的前台委派,只有一个结果,且没有冷恢复操作。
|
||||
可继续创建对应可选的 `SubagentProvider.prepareContinuable?()` 方法:方法是否存在就是能力检查,因此服务会在没有该方法的提供方上拒绝已配置的可继续启动,而具备该方法的提供方仍可服务普通一次性委派。该方法只返回分离的 `ContinuableCreateSpec`(`{ seed? }`)——这是数据,绝非能力:它不携带任何 Agent、`AgentHandle`、提示词投递、结果、dispose 或恢复操作,因为准备之后,继续执行管理器拥有身份预留、组合、Agent 创建、提示词投递、冷恢复、所有权和 dispose。一次性 `SubagentRun` 表示一次可 dispose 的前台委派,只有一个结果,且没有冷恢复操作。服务可以针对不同的同级子 agent 并发调用同一提供方:每次启动或准备都拥有各自的可变状态和取消路径,一项操作的失败、结果或清理不得使另一项操作结算或释放。提供方可以在内部按自身容量排队,但不得改变这项独立性约定。
|
||||
|
||||
## 持久化描述符
|
||||
|
||||
|
||||
@@ -277,7 +277,10 @@ export interface SubagentRun {
|
||||
/**
|
||||
* One registered transport for running child agents. Providers are trusted
|
||||
* same-process implementations; callers treat descriptors and returned values
|
||||
* as borrowed immutable data.
|
||||
* as borrowed immutable data. The service may call one provider concurrently
|
||||
* for distinct children. Providers isolate operation-local mutable state; a
|
||||
* shared capacity controller may delay an operation but must not couple its
|
||||
* settlement or cleanup to a sibling.
|
||||
*/
|
||||
export interface SubagentProvider {
|
||||
/** Unique registry name (e.g. `spawn`, `fork`, `acp`). */
|
||||
@@ -298,7 +301,8 @@ export interface SubagentProvider {
|
||||
* initial turn. Before fulfillment, the provider owns setup and cleans any
|
||||
* unpublished partial resources before rejecting. Ownership transfers on
|
||||
* fulfillment; subsequent turn or infrastructure failure settles through
|
||||
* the returned run.
|
||||
* the returned run. Distinct starts may overlap; cancellation, failure,
|
||||
* result settlement, and disposal remain independent for each run.
|
||||
*/
|
||||
start(request: ResolvedSubagentStartRequest): Promise<SubagentRun>
|
||||
/**
|
||||
@@ -313,6 +317,8 @@ export interface SubagentProvider {
|
||||
* continuation manager owns identity reservation, composition, Agent
|
||||
* creation, prompt delivery, cold resume, ownership, and disposal, so a
|
||||
* provider never sees the child's Agent, handle, turns, or teardown.
|
||||
* Distinct preparations may overlap; each follows its own signal and returns
|
||||
* data belonging only to `request.sessionId`.
|
||||
*/
|
||||
prepareContinuable?(request: ContinuableCreateRequest): Promise<ContinuableCreateSpec>
|
||||
}
|
||||
@@ -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/README.md
|
||||
README.md: a46300cc5dffcbc420ed9525d8f081c670771294
|
||||
README.zh.md: 985d33beb2ac7de60527b457c4786a0257232611
|
||||
README.md: d4c472d54f87d6006aef96079050cf14885ed8fb
|
||||
README.zh.md: 36f79ac879067dc74fcc6b96122088bb68d53321
|
||||
@@ -29,7 +29,7 @@ With `run_in_background: true`, `backgroundMode` selects the route. `one-shot` r
|
||||
|
||||
## Concurrency
|
||||
|
||||
Foreground and background calls are exclusive. Children may share the parent's workspace or external resources, and a unary classifier cannot prove that sibling delegations have disjoint effects. See the [parallel tool-call Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md).
|
||||
Foreground and background calls are concurrency-safe: sibling delegations in one assistant message overlap under the loop's rolling pool (`maxParallelToolCalls`), and results still commit in model order. Children work in their own sessions and a run never mutates the parent session; the one-shot background form's one parent-owned write — registering a Task — is a synchronous, commutative insertion that tolerates concurrent dispatch, so overlapping background calls acquire their task ids in dispatch-race order. Coordinating sibling workspace effects belongs to the model, exactly as it already does for background and continuable children. See the [parallel subagent Agent Note](../../../.agents/notes/implemented/feature/2026-08-09-parallel-subagent-delegations.md) and the [parallel tool-call Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md).
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
|
||||
## 并发
|
||||
|
||||
前台调用和后台调用均互斥。子 agent 可能共享父级工作区或外部资源,一元分类器无法证明同级委派的效果彼此不相交。见 [并行工具调用 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md)。
|
||||
前台调用和后台调用均并发安全:同一条 assistant 消息中的同级委派会在循环的滚动池(`maxParallelToolCalls`)下重叠执行,结果仍按模型顺序提交。子 agent 在各自的会话中工作,一次运行绝不变更父会话;一次性后台形态对父级拥有状态的唯一写入是注册一个 Task——这是一次同步、可交换、能容忍并发分发的插入,因此重叠的后台调用按分发竞态顺序获得各自的 task id。协调同级工作区效果由模型负责,正如模型已经对后台和可继续子 agent 所承担的那样。见 [并行 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-08-09-parallel-subagent-delegations.md) 和 [并行工具调用 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md)。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -331,6 +331,9 @@ export function apply(ctx: Context, config: Config): void {
|
||||
: outputValueText(value.output),
|
||||
}],
|
||||
},
|
||||
// Children never mutate the parent session; the one parent-owned write
|
||||
// (tasks.start) is a synchronous commutative insertion.
|
||||
isConcurrencySafe: () => true,
|
||||
async execute(args, exec) {
|
||||
const parent = exec.agent
|
||||
if (!parent) {
|
||||
|
||||
@@ -33,6 +33,8 @@ export interface Config {
|
||||
inheritsParentContext?: boolean
|
||||
/** Structured value returned when the request asks for one. */
|
||||
structured?: unknown
|
||||
/** Observes each start; the child's result additionally waits for the returned promise. */
|
||||
onStart?: (request: SubagentStartRequest) => Promise<void> | void
|
||||
}
|
||||
|
||||
/** Scripted provider whose result aborts if its signal or disposer wins first. */
|
||||
@@ -68,9 +70,10 @@ class ScriptedSubagentProvider implements SubagentProvider {
|
||||
...wantsStructured ? { structured: this.config.structured ?? { reply } } : {},
|
||||
stopReason: state.cancelled ? 'aborted' : stopReason,
|
||||
})
|
||||
const result = new Promise<SubagentResult>((resolve) => {
|
||||
const gate = Promise.resolve(this.config.onStart?.(request))
|
||||
const result = gate.then(() => new Promise<SubagentResult>((resolve) => {
|
||||
setTimeout(() => { resolve(resultFor()) }, 0)
|
||||
}).finally(() => {
|
||||
})).finally(() => {
|
||||
request.signal.removeEventListener('abort', onAbort)
|
||||
})
|
||||
|
||||
|
||||
@@ -128,20 +128,42 @@ describe('dsh-tool-subagent', () => {
|
||||
expect(foreground.isError).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps foreground and background calls exclusive', async () => {
|
||||
it('classifies foreground and background calls concurrency-safe (sibling delegations overlap)', async () => {
|
||||
const ctx = await setup({ provider: 'mock' })
|
||||
expect(ctx.tools.executionMode({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('subagent-foreground'),
|
||||
name: 'subagent',
|
||||
arguments: { description: 'do work', prompt: 'Reply OK' },
|
||||
})).toEqual({ kind: 'exclusive' })
|
||||
})).toEqual({ kind: 'parallel' })
|
||||
expect(ctx.tools.executionMode({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('subagent-background'),
|
||||
name: 'subagent',
|
||||
arguments: { description: 'do work', prompt: 'Reply OK', run_in_background: true },
|
||||
})).toEqual({ kind: 'exclusive' })
|
||||
})).toEqual({ kind: 'parallel' })
|
||||
})
|
||||
|
||||
it('overlaps sibling foreground delegations dispatched concurrently', async () => {
|
||||
// Two children each block until both have started: hidden serialization
|
||||
// in the tool body, registry pipeline, or provider start path would
|
||||
// deadlock here instead of passing silently.
|
||||
const started: string[] = []
|
||||
let releaseBoth!: () => void
|
||||
const bothStarted = new Promise<void>((resolve) => { releaseBoth = resolve })
|
||||
const ctx = await setup({ provider: 'mock', enableRunInBackground: false }, {
|
||||
onStart: (request: SubagentStartRequest) => {
|
||||
started.push(request.label ?? '(unlabeled)')
|
||||
if (started.length === 2) releaseBoth()
|
||||
return bothStarted
|
||||
},
|
||||
})
|
||||
const results = await Promise.all([
|
||||
callSubagent(ctx, { description: 'first', prompt: 'p1' }),
|
||||
callSubagent(ctx, { description: 'second', prompt: 'p2' }),
|
||||
])
|
||||
expect(started.sort()).toEqual(['first', 'second'])
|
||||
for (const result of results) expect(result.isError).toBe(false)
|
||||
})
|
||||
|
||||
it.each([
|
||||
@@ -957,6 +979,16 @@ describe('dsh-tool-subagent continuable background mode', () => {
|
||||
return { ctx, parent }
|
||||
}
|
||||
|
||||
it('classifies continuable background calls concurrency-safe', async () => {
|
||||
const { ctx } = await continuableSetup()
|
||||
expect(ctx.tools.executionMode({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('subagent-continuable'),
|
||||
name: 'subagent',
|
||||
arguments: { description: 'do work', prompt: 'Reply OK', run_in_background: true },
|
||||
})).toEqual({ kind: 'parallel' })
|
||||
})
|
||||
|
||||
it('starts a continuable child and returns only its durable id, creating no Task', async () => {
|
||||
const { ctx, parent } = await continuableSetup()
|
||||
const schema = ctx.tools.schemas().find(s => s.name === 'subagent')!
|
||||
@@ -986,6 +1018,69 @@ describe('dsh-tool-subagent continuable background mode', () => {
|
||||
expect(loaded.events.some(event => event.type === 'assistant/message')).toBe(true)
|
||||
})
|
||||
|
||||
it('isolates a cancelled continuable preparation from a concurrent sibling', async () => {
|
||||
const { ctx, parent } = await continuableSetup()
|
||||
const bothPreparing = Promise.withResolvers<undefined>()
|
||||
const releasePreparations = Promise.withResolvers<undefined>()
|
||||
const cancelled = new AbortController()
|
||||
let preparationCount = 0
|
||||
let cancelledChildId: ReturnType<typeof SessionId> | undefined
|
||||
let survivingChildId: ReturnType<typeof SessionId> | undefined
|
||||
ctx.subagents.registerProvider({
|
||||
name: 'gated',
|
||||
capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: true },
|
||||
inheritsParentContext: false,
|
||||
start: async () => { throw new Error('continuable policy must not start a one-shot child') },
|
||||
prepareContinuable: async (request) => {
|
||||
preparationCount += 1
|
||||
if (request.signal === cancelled.signal) cancelledChildId = request.sessionId
|
||||
else survivingChildId = request.sessionId
|
||||
if (preparationCount === 2) bothPreparing.resolve(undefined)
|
||||
await releasePreparations.promise
|
||||
return {}
|
||||
},
|
||||
})
|
||||
tool.apply(ctx, {
|
||||
provider: 'gated',
|
||||
toolName: 'subagent_gated',
|
||||
backgroundMode: 'continuable',
|
||||
maxDepth: 3,
|
||||
})
|
||||
|
||||
const execute = (callId: string, description: string, signal: AbortSignal) => ctx.tools.execute({
|
||||
signal,
|
||||
callId: CallId(callId),
|
||||
name: 'subagent_gated',
|
||||
arguments: { description, prompt: 'work', run_in_background: true },
|
||||
agent: parent,
|
||||
})
|
||||
const cancelledResult = execute('continuable-cancelled', 'cancelled sibling', cancelled.signal)
|
||||
const survivingResult = execute('continuable-surviving', 'surviving sibling', testToolSignal)
|
||||
await bothPreparing.promise
|
||||
cancelled.abort()
|
||||
releasePreparations.resolve(undefined)
|
||||
|
||||
const [failed, succeeded] = await Promise.all([cancelledResult, survivingResult])
|
||||
expect(preparationCount).toBe(2)
|
||||
expect(failed.isError).toBe(true)
|
||||
expect(succeeded.isError).toBe(false)
|
||||
expect(cancelledChildId).toBeDefined()
|
||||
expect(survivingChildId).toBeDefined()
|
||||
expect(ctx.agents.get(cancelledChildId!)).toBeUndefined()
|
||||
await expect(ctx.sessionPersistence.load(cancelledChildId!)).rejects.toThrow(/not found/)
|
||||
|
||||
expect(succeeded.isError ? undefined : succeeded.value).toEqual({
|
||||
kind: 'continuable',
|
||||
subagentId: survivingChildId,
|
||||
})
|
||||
await vi.waitFor(() => {
|
||||
expect(ctx.agents.get(survivingChildId!)).toBeUndefined()
|
||||
}, { timeout: 5_000 })
|
||||
const loaded = await ctx.sessionPersistence.load(survivingChildId!)
|
||||
expect(loaded.events.some(event => event.type === 'subagent/descriptor')).toBe(true)
|
||||
expect(loaded.events.some(event => event.type === 'assistant/message')).toBe(true)
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
describe('background preflight failure (no orphaned child, by construction)', () => {
|
||||
|
||||
Reference in New Issue
Block a user