feat(agent-loop): run safe tool calls in parallel

This commit is contained in:
Dudu-0223
2026-07-13 11:02:21 +08:00
parent 4cda9dd03d
commit 7ea1bf119f
48 changed files with 1542 additions and 141 deletions

View File

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

View File

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

View File

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