fix(agent-loop): tighten parallel tool-call safety
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user