refactor(agent-loop): unify tool-call scheduling on one rolling pool + factory cap default

Run every ordered group through the same rolling pool: an exclusive call is a
pool of one (a barrier), dropping the separate runExclusive path and the
redundant post-grouping executionMode re-query. Behavior is unchanged — the
parallel-tool-calls snapshot and the full scheduler unit suite (barriers, cap,
abort, model-order results) stay green.

Add AgentLoop.Config.maxParallelToolCalls as a factory-wide default applied to
every agent create/createAgent/resume mints (per-agent option overrides it),
forwarded through agent-core so it reaches front doors that expose no cap field
of their own. Trim the isConcurrencySafe JSDoc to the local contract and link
the parallel-tool-call RFC for the full rationale; document the field on the
canonical core-data-structures page.
This commit is contained in:
Dudu-0223
2026-07-16 11:36:16 +08:00
parent 77be4b891b
commit 3b1d1bfa12
12 changed files with 162 additions and 88 deletions

View File

@@ -76,6 +76,11 @@ Source: [`packages/ui/acp-agent/src/index.ts:31`](../packages/ui/acp-agent/src/i
export interface Config {
/** The agent-loop `agents` list (see dsh-agent-loop's `Config`). */
agents?: AgentLoopConfig['agents']
/**
* The factory-wide default concurrent tool-call cap applied to every agent
* this bundle creates (see dsh-agent-loop's `Config.maxParallelToolCalls`).
*/
maxParallelToolCalls?: AgentLoopConfig['maxParallelToolCalls']
/** The deployment persona (see dsh-system-prompt's `Config`). */
persona?: SystemPromptConfig['persona']
/** The explicit model-facing tool order (see dsh-system-prompt's `Config`). */
@@ -108,6 +113,16 @@ Requires: `agents` · `sessions` · `llm` · `tools` · `systemPrompt`
```ts config-catalog
/** Plugin configuration for declarative startup agents. */
export interface Config {
/**
* Default concurrent tool-call cap applied to every agent this factory
* creates (declarative startup agents and factory callers such as the ACP,
* stdio, and SDK front doors that go through `create`/`createAgent`/`resume`).
* A positive integer; a per-agent `maxParallelToolCalls` overrides it, and an
* agent with neither falls back to {@link DEFAULT_MAX_PARALLEL_TOOL_CALLS}.
* This is the single `cordis.yml` knob that reaches agents whose front door
* does not expose its own cap field.
*/
maxParallelToolCalls?: number
/** Agents created or resumed at plugin startup. */
agents: (AgentOptions & {
/** Registry identity for the live agent. */
@@ -964,7 +979,7 @@ export interface Config {
export type ToolPresentationMode = 'native' | 'code' | 'both'
```
Source: [`packages/core/tools/src/index.ts:391`](../packages/core/tools/src/index.ts)
Source: [`packages/core/tools/src/index.ts:389`](../packages/core/tools/src/index.ts)
## `@deepseek-ai/dsh-user-approval`

View File

@@ -19,7 +19,7 @@ async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise<Agent
async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise<AgentHandle>
```
Source: [`packages/core/agent-loop/src/index.ts:364`](../../packages/core/agent-loop/src/index.ts)
Source: [`packages/core/agent-loop/src/index.ts:374`](../../packages/core/agent-loop/src/index.ts)
## `ctx.agents` — `AgentRegistry`
@@ -257,7 +257,7 @@ async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>
Types: [ToolDefinition](../core-data-structures/tools.md) · [ToolExecutionInput](../core-data-structures/tools.md) · [ToolExecutionMode](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md)
Source: [`packages/core/tools/src/index.ts:447`](../../packages/core/tools/src/index.ts)
Source: [`packages/core/tools/src/index.ts:445`](../../packages/core/tools/src/index.ts)
## `ctx.userInteraction` — `UserInteractionService`

View File

@@ -347,7 +347,7 @@ interface Agent {
}
```
`AgentStatus` is `'idle' | 'running' | 'disposed'`, and `AgentId` is branded. `AgentOptions` is merge-extensible and currently includes `model?`. Persona belongs to `dsh-system-prompt`: an agent-scoped `deployment:persona` may shadow the global default.
`AgentStatus` is `'idle' | 'running' | 'disposed'`, and `AgentId` is branded. `AgentOptions` is merge-extensible and currently includes `model?` and `maxParallelToolCalls?` (the loop's per-agent concurrent tool-call cap; the owning field, defaulted by `AgentLoop.Config` and, absent that, by `DEFAULT_MAX_PARALLEL_TOOL_CALLS`). Persona belongs to `dsh-system-prompt`: an agent-scoped `deployment:persona` may shadow the global default.
The [event taxonomy](../architecture.md#event) owns the `agent/*` lifecycle, checkpoint, and waterfall contracts. Turn and step boundaries are durable session events rather than agent emits.

View File

@@ -30,20 +30,18 @@ interface ToolDefinition extends ToolSchema {
*
* It may inspect the parsed `args` (`unknown` — a hand-rolled definition
* receives the raw parsed value; `defineTool` schema-validates first and
* returns `false` on invalid args, so an eventual `ToolArgsError` is produced
* only if the tool actually executes). The check performs no I/O and receives
* no live `Agent` or mutable `ToolExecution`.
* returns `false` on invalid args). The check performs no I/O and receives no
* live `Agent` or mutable `ToolExecution`.
*
* Declaring `true` is a contract: the tool body must NOT mutate the parent
* agent's session or other parent-owned async state during `execute` (no
* `exec.agent.session.append(...)`, no `agent.inject(...)`). Its only parent-
* Declaring `true` is a contract: during `execute` the tool body must NOT
* mutate the parent agent's session or other parent-owned async state (no
* `exec.agent.session.append(...)`, no `agent.inject(...)`); its only parent-
* step outputs are the returned content, `meta`, structured error, and
* `additionalContext` carried through the loop's ordered post-execute path.
* The narrow exception is a synchronous, side-effect-only recorder whose
* updates are commutative OR fail closed for concurrent calls by the same
* session (the `fs/observed` version recorder is the worked example: its
* WeakMap record is last-writer-wins, and a stale observation only makes a
* later write/edit fail closed at its in-lock version CAS).
* `additionalContext` on the loop's ordered post-execute path. A synchronous,
* side-effect-only recorder whose updates are commutative or fail closed for
* concurrent same-session calls is the one exception (`fs/observed` is the
* worked example). Full contract and rationale: the parallel-tool-call RFC
* (docs/rfc/implemented/feature/2026-07-10-parallel-tool-call-execution.md).
*/
isConcurrencySafe?(args: unknown): boolean
/**

View File

@@ -45,11 +45,11 @@ A parallel-safe declaration is a contract. The tool body must not mutate the par
The loop waits for the model stream to finish and logs one authoritative `assistant/message` before scheduling tools. Streaming tool execution is out of scope.
For each assistant step, `packages/core/agent-loop/src/tool-calls.ts` parses each call's raw JSON arguments exactly once, creates one distinct `ToolExecution` object per call, asks `ctx.tools.executionMode(exec)`, and partitions calls into ordered groups. A group is either one exclusive call or a run of consecutive parallel calls. `loop.ts` calls the helper so the turn/step lifecycle remains readable.
For each assistant step, `packages/core/agent-loop/src/tool-calls.ts` parses each call's raw JSON arguments exactly once, creates one distinct `ToolExecution` object per call, asks `ctx.tools.executionMode(exec)`, and partitions calls into ordered groups. A group is either one exclusive call or a run of consecutive parallel calls; grouping classifies each call exactly once. `loop.ts` calls the helper so the turn/step lifecycle remains readable.
Parallelism is per agent. `AgentOptions.maxParallelToolCalls` is a positive integer, defaults to `DEFAULT_MAX_PARALLEL_TOOL_CALLS` (`10`), and is accepted through the config-created agent path. Setting it to `1` preserves serial execution for that agent. Both the TypeScript `AgentOptions` vocabulary and the `AgentLoop.Config` schemastery object validate the cap, so invalid `cordis.yml` values fail during config validation.
Parallelism is per agent. `AgentOptions.maxParallelToolCalls` is a positive integer, defaults to `DEFAULT_MAX_PARALLEL_TOOL_CALLS` (`10`), and reaches an agent three ways in precedence order: the per-agent option, the factory-wide `AgentLoop.Config.maxParallelToolCalls` applied to every agent the loop creates (declarative startup agents and factory callers such as the ACP, stdio, and SDK front doors), then the built-in default. The factory default is the single `cordis.yml` knob for agents whose front door exposes no cap field of its own. Setting the value to `1` preserves serial execution for that agent. The TypeScript `AgentOptions` vocabulary and both `AgentLoop.Config` fields validate the cap, so invalid `cordis.yml` values fail during config validation.
Within a parallel group, execution uses a rolling pool: start calls in model order up to `maxParallelToolCalls`, and whenever one call settles, start the next unstarted call until the group is exhausted. A group larger than the cap is not truncated; the cap limits simultaneous in-flight calls only.
Every group runs through the same rolling pool: start calls in model order up to `maxParallelToolCalls`, and whenever one call settles, start the next unstarted call until the group is exhausted. An exclusive group is a pool of one — a barrier — so the loop needs no separate serial path. A group larger than the cap is not truncated; the cap limits simultaneous in-flight calls only.
Only the dispatch/body stage runs concurrently. Generic middleware that can shape ordering-sensitive state remains ordered: `tools/pre-execute` and `tools/post-execute` run in model call order. `@deepseek-ai/dsh-tools` exposes the symbol-keyed internal `TOOL_REGISTRY_SCHEDULER` view so `dsh-agent-loop` can split prepare, dispatch, and finalize without adding named staged service methods to `ctx.tools`; ordinary callers still use the one-call `execute(exec)` API. `tools/execute` around-dispatch listeners run with the dispatch they wrap, so wrappers must be reentrant across distinct `ToolExecution` objects. The shipped timeout policy is per-call: every call owns its mutable `exec` and deadline.