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/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