fix(agent-loop): tighten parallel tool-call safety

This commit is contained in:
Dudu-0223
2026-07-16 15:52:35 +08:00
parent bbf66b3a5b
commit 17fe9e5b1e
26 changed files with 88 additions and 67 deletions

View File

@@ -26,7 +26,7 @@ With `run_in_background: true`, the tool registers the parent-owned task before
## Concurrency
Foreground calls opt into concurrent scheduling because each owns an independent child run and returns only its final answer. Background starts remain exclusive because they register parent-owned task state. Providers must accept concurrent `start()` calls for independent runs; they may queue internally, enforce capacity, or return a typed failure. See the [parallel tool-call RFC](../../../docs/rfc/implemented/feature/2026-07-10-parallel-tool-call-execution.md).
Foreground and background calls are exclusive. Children may share the parent's workspace or external resources, and the unary scheduler classifier cannot prove that sibling delegations have disjoint effects. See the [parallel tool-call RFC](../../../docs/rfc/implemented/feature/2026-07-10-parallel-tool-call-execution.md).
## Model Experience

View File

@@ -174,8 +174,7 @@ export function providerWording(inheritsConversation: boolean): { description: s
+ 'completed turns so far (it does not see the current in-flight turn), returning only its final '
+ 'result. Use this when the subtask builds on this conversation\'s context — a follow-up analysis, '
+ 'a review, a continuation — without consuming this conversation\'s context for the work itself. '
+ 'You receive only its final answer, not its intermediate steps. You may issue several subagent '
+ 'calls in one message to run independent tasks concurrently when their work scopes do not overlap.',
+ 'You receive only its final answer, not its intermediate steps.',
promptDescription:
'The task for the subagent. It already sees this conversation\'s completed turns, so build on them '
+ 'freely and state only what is new.',
@@ -187,8 +186,7 @@ export function providerWording(inheritsConversation: boolean): { description: s
+ 'and return its final result. Use this to offload focused, independent work — research, a scoped '
+ 'implementation, an analysis — so it does not consume this conversation\'s context. The subagent '
+ 'runs to completion and you receive only its final answer, not its intermediate steps. Give it a '
+ 'complete, standalone prompt: it does not see this conversation. You may issue several subagent '
+ 'calls in one message to run independent tasks concurrently when their work scopes do not overlap.',
+ 'complete, standalone prompt: it does not see this conversation.',
promptDescription:
'The complete, self-contained task for the subagent. It does not share this '
+ 'conversation\'s context, so include everything it needs.',
@@ -254,9 +252,6 @@ export function apply(ctx: Context, config: Config): void {
},
} : {},
},
// A foreground call owns only its child run; background mode first
// registers parent-owned task state and therefore remains exclusive.
isConcurrencySafe: args => args.run_in_background !== true,
async execute(args, exec): Promise<ContentBlock[]> {
const parent = exec.agent
if (!parent) {

View File

@@ -96,13 +96,13 @@ describe('dsh-tool-subagent', () => {
expect(foreground.isError).toBe(false)
})
it('classifies foreground calls as parallel and background starts as exclusive', async () => {
it('keeps foreground and background calls exclusive', async () => {
const ctx = await setup({ provider: 'mock' })
expect(ctx.tools.executionMode({
callId: CallId('subagent-safe'),
callId: CallId('subagent-foreground'),
name: 'subagent',
arguments: { description: 'do work', prompt: 'Reply OK' },
})).toEqual({ kind: 'parallel' })
})).toEqual({ kind: 'exclusive' })
expect(ctx.tools.executionMode({
callId: CallId('subagent-background'),
name: 'subagent',