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:
Hypatia May
2026-08-11 13:55:11 +08:00
142 changed files with 3934 additions and 262 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/tool-subagent/README.md
README.md: a46300cc5dffcbc420ed9525d8f081c670771294
README.zh.md: 985d33beb2ac7de60527b457c4786a0257232611
README.md: d4c472d54f87d6006aef96079050cf14885ed8fb
README.zh.md: 36f79ac879067dc74fcc6b96122088bb68d53321

View File

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

View File

@@ -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)。
## 模型体验

View File

@@ -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) {

View File

@@ -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)
})

View File

@@ -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)', () => {