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

@@ -14,6 +14,4 @@ The subagent seam: an agent delegating work to a child agent. Like the [bash](..
The interface lives at `subagent/subagent/`. The in-process `subagent-spawn` / `subagent-fork` backends share the `subagent-inprocess` driver (a library with no provider of its own — both depend on it, neither on the other), the out-of-process `subagent-acp` backend builds on the `subagent-subprocess` library (the credential env scrub, the dispose ladder, isolated config dirs) and ships alongside them here; the test-only `dsh-subagent-mock` (in [support](../support/README.md)) is separate. All **product** packages except the mock.
`SubagentProvider.start()` must be safe to call concurrently for independent runs: foreground `subagent` calls are parallel-safe, so one parent step may issue several at once. Background starts remain exclusive while registering parent-owned task state. Each backend reads the parent synchronously at start (a snapshot, never mutated or re-read during the run) — `fork` seeds each child from the parent's completed-turn prefix, which the open in-flight turn cannot change, so concurrent forks inside one open step all see the same stable prefix. A resource-limited provider may queue or cap internally, but must not require the loop to serialize every foreground call.
The proposal and design rationale: [docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md](../../docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md).

View File

@@ -186,13 +186,6 @@ export interface SubagentProvider {
* honorable when present. If setup fails or `request.signal` aborts before
* fulfillment, the provider owns and cleans all partial resources before this
* promise rejects. Ownership transfers to the caller only on fulfillment.
*
* MUST be safe to call concurrently for independent runs: foreground
* `subagent` calls are parallel-safe, so a parent step may issue several at once,
* each invoking `start()` before an earlier run settles. An implementation
* snapshots the parent at start and must not require the parent loop to
* serialize every foreground `subagent` call; a resource-limited provider queues or
* rejects internally.
*/
start(request: SubagentStartRequest): Promise<SubagentRun>
}

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',