feat(agent-loop): run safe tool calls in parallel
This commit is contained in:
@@ -14,4 +14,6 @@ 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 pure library — 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: the `subagent` tool is parallel-safe, so one parent step may issue several subagent calls at once. 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 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).
|
||||
|
||||
@@ -182,6 +182,19 @@ export interface SubagentProvider {
|
||||
* Start a child run. The service has already validated that every requested
|
||||
* start-time capability is supported, so an implementation may assume e.g.
|
||||
* `request.maxDepth` is honorable when present.
|
||||
*
|
||||
* MUST be safe to call concurrently for independent runs: the `subagent` tool
|
||||
* is parallel-safe, so a parent step may issue several subagent calls at once,
|
||||
* each invoking `start()` before an earlier run settles. An implementation
|
||||
* reads the parent SYNCHRONOUSLY at start (a snapshot — never mutating or
|
||||
* re-reading it during the run) so concurrent starts inside the parent's one
|
||||
* open step all observe the same stable state; the fork backend seeds each
|
||||
* child from the parent's completed-turn prefix, which the open in-flight turn
|
||||
* cannot change. A provider backed by a limited resource may queue internally,
|
||||
* apply its own capacity cap, or return a typed failure for the affected run —
|
||||
* but it must NOT require the parent loop to serialize every `subagent` call.
|
||||
* @param request - the start request (prompt, parent, and any start-time options).
|
||||
* @returns the started {@link SubagentRun}.
|
||||
*/
|
||||
start(request: SubagentStartRequest): SubagentRun
|
||||
}
|
||||
|
||||
@@ -21,3 +21,7 @@ The tool description and the `prompt` parameter description are DERIVED from the
|
||||
`execute` starts a run on the configured provider and **awaits `run.result` inside a `try/finally` that always `dispose()`s the run** — the owned child agent/session is torn down on every path (success, error, abort), never leaked. The tool's abort signal (`exec.signal`) is bridged to `run.cancel()`. A non-`completed` stop reason (aborted/error/max-tokens/refusal) maps to an `isError` tool result rather than returning partial output as success.
|
||||
|
||||
Background / poll collection is deferred (see the [RFC](../../../docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md)); this cut blocks the parent turn until the child finishes.
|
||||
|
||||
## Concurrency
|
||||
|
||||
The tool declares `isConcurrencySafe: () => true`: each call starts an independent child run and returns only its final answer, touching no parent-agent state, and `SubagentProvider.start()` is contractually concurrent-safe for independent runs (see [subagent/](../README.md)). So the agent loop may run several `subagent` calls from one assistant step in parallel, and the tool description tells the model it may issue independent tasks together when their work scopes do not overlap. The subagent tool stays synchronous (one result = the child's final answer); background spawning + later collection is separate future work.
|
||||
|
||||
@@ -119,7 +119,8 @@ export function providerWording(inherits: boolean): { description: string; promp
|
||||
+ '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 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.',
|
||||
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.',
|
||||
@@ -131,7 +132,8 @@ export function providerWording(inherits: boolean): { description: string; promp
|
||||
+ '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.',
|
||||
+ '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.',
|
||||
promptDescription:
|
||||
'The complete, self-contained task for the subagent. It does not share this '
|
||||
+ 'conversation\'s context, so include everything it needs.',
|
||||
@@ -165,6 +167,12 @@ export function apply(ctx: Context, config: Config): void {
|
||||
description: wording.promptDescription,
|
||||
},
|
||||
},
|
||||
// Each call starts an independent child run and returns only its final
|
||||
// answer; the tool touches no parent-agent state. SubagentProvider.start()
|
||||
// is contractually safe to call concurrently for independent runs (a
|
||||
// resource-limited provider queues internally), so sibling subagent calls
|
||||
// may run in parallel.
|
||||
isConcurrencySafe: () => true,
|
||||
async execute(args, exec): Promise<ContentBlock[]> {
|
||||
const parent = exec.agent
|
||||
if (!parent) {
|
||||
|
||||
@@ -68,6 +68,15 @@ describe('dsh-tool-subagent', () => {
|
||||
expect(Object.keys(props).sort()).toEqual(['description', 'prompt'])
|
||||
})
|
||||
|
||||
it('declares each subagent call parallel-safe through the shared tool scheduler contract', async () => {
|
||||
const ctx = await setup({ provider: 'mock' })
|
||||
expect(ctx.tools.executionMode({
|
||||
callId: CallId('subagent-safe'),
|
||||
name: 'subagent',
|
||||
arguments: { description: 'do work', prompt: 'Reply OK' },
|
||||
})).toEqual({ kind: 'parallel' })
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ stopReason: 'aborted' as const, fragment: 'cancelled' },
|
||||
{ stopReason: 'error' as const, fragment: 'failed' },
|
||||
|
||||
Reference in New Issue
Block a user