docs: tighten parallel tool-call prose
This commit is contained in:
@@ -34,14 +34,14 @@ sequenceDiagram
|
|||||||
Session-->>SDK: <code>session/event</code> <code>assistant/chunk</code>*
|
Session-->>SDK: <code>session/event</code> <code>assistant/chunk</code>*
|
||||||
Driver->>Hooks: <code>agent/step-result</code> waterfall
|
Driver->>Hooks: <code>agent/step-result</code> waterfall
|
||||||
Driver->>Session: <code>assistant/message</code>
|
Driver->>Session: <code>assistant/message</code>
|
||||||
Driver->>Tools: classify next call by executionMode
|
Driver->>Tools: classify pending call by executionMode
|
||||||
loop bounded rolling pool with reclassification before replenishing
|
loop barriers and bounded rolling pool, reclassify before start
|
||||||
opt capacity available for an unstarted call
|
opt call starts
|
||||||
Driver->>Session: <code>tool/call</code> pending audit
|
Driver->>Session: <code>tool/call</code>
|
||||||
Driver->>Tools: ordered pre / pooled dispatch
|
Driver->>Tools: ordered pre, concurrent execute
|
||||||
Tools-->>Session: tool-owned events when applicable
|
Tools-->>Session: tool-owned events when applicable
|
||||||
end
|
end
|
||||||
opt next model-order result is ready
|
opt next model-order result ready
|
||||||
Driver->>Tools: ordered post
|
Driver->>Tools: ordered post
|
||||||
Driver->>Session: <code>tool/result</code>
|
Driver->>Session: <code>tool/result</code>
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -82,11 +82,11 @@ forever:
|
|||||||
'assistant/chunk'
|
'assistant/chunk'
|
||||||
agent/step-result
|
agent/step-result
|
||||||
'assistant/message'
|
'assistant/message'
|
||||||
schedule tool calls by ctx.tools.executionMode (reclassify before pool replenishment;
|
schedule tool calls by ctx.tools.executionMode:
|
||||||
exclusive = barrier; parallel-safe = rolling pool, <= maxParallelToolCalls in flight):
|
exclusive -> one-call barrier
|
||||||
while the bounded pool has work:
|
parallel -> rolling pool, <= maxParallelToolCalls in flight; reclassify before start
|
||||||
capacity available -> 'tool/call' -> tools/pre-execute -> monotonic guards -> tools/execute
|
each start -> 'tool/call' -> ordered tools/pre-execute -> concurrent tools/execute
|
||||||
next model-order slot ready -> tools/post-execute -> 'tool/result'
|
each model-order result -> ordered tools/post-execute -> 'tool/result'
|
||||||
append post-tool context (model order) and steering
|
append post-tool context (model order) and steering
|
||||||
'step/end'
|
'step/end'
|
||||||
agent/turn-continuation
|
agent/turn-continuation
|
||||||
|
|||||||
@@ -39,18 +39,14 @@ Source: [`packages/ui/acp/src/index.ts:208`](../packages/ui/acp/src/index.ts)
|
|||||||
* deployment persona (forwarded to the system-prompt plugin); `toolOrder` is
|
* deployment persona (forwarded to the system-prompt plugin); `toolOrder` is
|
||||||
* the explicit model-facing tool order (forwarded to the system-prompt plugin);
|
* the explicit model-facing tool order (forwarded to the system-prompt plugin);
|
||||||
* `tools` is the tool registry's config (its presentation `mode`, forwarded
|
* `tools` is the tool registry's config (its presentation `mode`, forwarded
|
||||||
* through agent-spine-demo); `maxParallelToolCalls` configures the bundled
|
* through agent-spine-demo); `persistenceRoot` is the JSONL backend's directory.
|
||||||
* agent loop; `persistenceRoot` is the JSONL backend's directory.
|
|
||||||
*/
|
*/
|
||||||
export interface Config {
|
export interface Config {
|
||||||
/** Provider route for ACP-created agents. */
|
/** Provider route for ACP-created agents. */
|
||||||
provider: string
|
provider: string
|
||||||
/** Model name for ACP-created agents (must have a registered adapter). */
|
/** Model name for ACP-created agents (must have a registered adapter). */
|
||||||
model: string
|
model: string
|
||||||
/**
|
/** Bundled agent-loop concurrency cap; `1` is serial and omission uses its default. */
|
||||||
* Concurrent parallel-safe tool-call cap for the bundled agent loop. A
|
|
||||||
* positive integer; the loop defaults it when omitted and `1` is serial.
|
|
||||||
*/
|
|
||||||
maxParallelToolCalls?: number
|
maxParallelToolCalls?: number
|
||||||
/** Deployment persona (the system-prompt plugin's `persona` config). */
|
/** Deployment persona (the system-prompt plugin's `persona` config). */
|
||||||
persona?: string
|
persona?: string
|
||||||
@@ -75,7 +71,7 @@ export interface Config {
|
|||||||
|
|
||||||
Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools)
|
Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools)
|
||||||
|
|
||||||
Source: [`packages/examples/acp-demo/src/index.ts:33`](../packages/examples/acp-demo/src/index.ts)
|
Source: [`packages/examples/acp-demo/src/index.ts:32`](../packages/examples/acp-demo/src/index.ts)
|
||||||
|
|
||||||
## `@deepseek-ai/dsh-agent-loop`
|
## `@deepseek-ai/dsh-agent-loop`
|
||||||
|
|
||||||
@@ -85,9 +81,8 @@ Requires: `agents` · `sessions` · `llm` · `tools` · `systemPrompt`
|
|||||||
/** Agent-loop plugin configuration. */
|
/** Agent-loop plugin configuration. */
|
||||||
export interface Config {
|
export interface Config {
|
||||||
/**
|
/**
|
||||||
* Concurrent parallel-safe tool-call cap shared by every agent this factory
|
* Maximum parallel-safe calls in flight per agent step. `1` is serial;
|
||||||
* creates. A positive integer; `1` preserves fully serial execution and an
|
* omission defaults to {@link DEFAULT_MAX_PARALLEL_TOOL_CALLS}.
|
||||||
* omitted value defaults to {@link DEFAULT_MAX_PARALLEL_TOOL_CALLS}.
|
|
||||||
*/
|
*/
|
||||||
maxParallelToolCalls?: number
|
maxParallelToolCalls?: number
|
||||||
/** Agents created or resumed at plugin startup. */
|
/** Agents created or resumed at plugin startup. */
|
||||||
@@ -127,7 +122,7 @@ Source: [`packages/core/agent-loop/src/index.ts:334`](../packages/core/agent-loo
|
|||||||
export interface Config {
|
export interface Config {
|
||||||
/** The agent-loop `agents` list (see dsh-agent-loop's `Config`). */
|
/** The agent-loop `agents` list (see dsh-agent-loop's `Config`). */
|
||||||
agents?: AgentLoopConfig['agents']
|
agents?: AgentLoopConfig['agents']
|
||||||
/** Shared concurrent tool-call cap (see dsh-agent-loop's `Config`). */
|
/** Agent-loop concurrency cap; `1` is serial. */
|
||||||
maxParallelToolCalls?: AgentLoopConfig['maxParallelToolCalls']
|
maxParallelToolCalls?: AgentLoopConfig['maxParallelToolCalls']
|
||||||
/** The deployment persona (see dsh-system-prompt's `Config`). */
|
/** The deployment persona (see dsh-system-prompt's `Config`). */
|
||||||
persona?: SystemPromptConfig['persona']
|
persona?: SystemPromptConfig['persona']
|
||||||
@@ -814,10 +809,7 @@ export interface Config {
|
|||||||
provider: string
|
provider: string
|
||||||
/** Model name for the `main` agent (must have a registered adapter). */
|
/** Model name for the `main` agent (must have a registered adapter). */
|
||||||
model: string
|
model: string
|
||||||
/**
|
/** Bundled agent-loop concurrency cap; `1` is serial and omission uses its default. */
|
||||||
* Concurrent parallel-safe tool-call cap for the bundled agent loop. A
|
|
||||||
* positive integer; the loop defaults it when omitted and `1` is serial.
|
|
||||||
*/
|
|
||||||
maxParallelToolCalls?: number
|
maxParallelToolCalls?: number
|
||||||
/** Deployment persona (the system-prompt plugin's `persona` config). */
|
/** Deployment persona (the system-prompt plugin's `persona` config). */
|
||||||
persona?: string
|
persona?: string
|
||||||
@@ -1215,7 +1207,7 @@ export interface Config {
|
|||||||
export type ToolPresentationMode = 'native' | 'code' | 'both'
|
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:382`](../packages/core/tools/src/index.ts)
|
||||||
|
|
||||||
## `@deepseek-ai/dsh-user-approval`
|
## `@deepseek-ai/dsh-user-approval`
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise<Agent
|
|||||||
async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise<AgentHandle>
|
async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise<AgentHandle>
|
||||||
```
|
```
|
||||||
|
|
||||||
Source: [`packages/core/agent-loop/src/index.ts:353`](../../packages/core/agent-loop/src/index.ts)
|
Source: [`packages/core/agent-loop/src/index.ts:352`](../../packages/core/agent-loop/src/index.ts)
|
||||||
|
|
||||||
## `ctx.agents` — `AgentRegistry`
|
## `ctx.agents` — `AgentRegistry`
|
||||||
|
|
||||||
@@ -312,7 +312,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)
|
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:438`](../../packages/core/tools/src/index.ts)
|
||||||
|
|
||||||
## `ctx.userInteraction` — `UserInteractionService`
|
## `ctx.userInteraction` — `UserInteractionService`
|
||||||
|
|
||||||
|
|||||||
@@ -20,28 +20,15 @@ interface ToolDefinition extends ToolSchema {
|
|||||||
*/
|
*/
|
||||||
timeoutMs?: number
|
timeoutMs?: number
|
||||||
/**
|
/**
|
||||||
* Optional synchronous, pure classification: may this call run concurrently
|
* Pure synchronous classifier for overlap with sibling tool calls. Only
|
||||||
* with other tool calls in the same assistant step? The agent-loop scheduler
|
* `true` opts in; omission, exceptions, non-`true` returns, and invalid
|
||||||
* calls it (via {@link ToolRegistry.executionMode}) to decide whether the call
|
* `defineTool` arguments are exclusive. This metadata is never model-visible.
|
||||||
* joins a parallel group or forms an exclusive barrier; a missing declaration,
|
|
||||||
* a thrown check, or any non-`true` return is treated as exclusive. Like
|
|
||||||
* `timeoutMs` it is host-only scheduler metadata — NEVER sent to the model,
|
|
||||||
* since `schemas()` whitelists only name/description/parameters.
|
|
||||||
*
|
*
|
||||||
* It may inspect the parsed `args` (`unknown` — a hand-rolled definition
|
* Opted-in executions must not mutate parent-owned state. Shared state must
|
||||||
* receives the raw parsed value; `defineTool` schema-validates first and
|
* tolerate concurrent dispatch; recorder races are permitted only when they
|
||||||
* returns `false` on invalid args). The check performs no I/O and receives no
|
* commute or fail closed. See the parallel-tool-call RFC for the full contract.
|
||||||
* live `Agent` or mutable `ToolExecution`.
|
* @param args - parsed arguments; `defineTool` validates before calling.
|
||||||
*
|
* @returns Whether this call may join a parallel group.
|
||||||
* 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` 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
|
isConcurrencySafe?(args: unknown): boolean
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -16,11 +16,11 @@ Each tool may provide an optional `isConcurrencySafe(args)` classifier. It is sy
|
|||||||
|
|
||||||
The classifier is deliberately unary. Returning `true` is the tool's promise that this call may overlap with any sibling call that also returns `true`; the scheduler does not compare calls or prove that their resource accesses are compatible.
|
The classifier is deliberately unary. Returning `true` is the tool's promise that this call may overlap with any sibling call that also returns `true`; the scheduler does not compare calls or prove that their resource accesses are compatible.
|
||||||
|
|
||||||
Arguments still support input-sensitive classification. A tool may classify a read-only operation as parallel and a mutating operation as exclusive. The interface cannot express relational rules such as "these writes are safe only when their paths differ," so a call whose safety depends on a sibling remains exclusive.
|
The unary classifier remains input-sensitive. A tool may classify a read-only operation as parallel and a mutating operation as exclusive. The interface cannot express relational rules such as "these writes are safe only when their paths differ," so a call whose safety depends on a sibling remains exclusive.
|
||||||
|
|
||||||
`defineTool()` validates arguments before invoking a typed classifier. Invalid arguments classify as exclusive and produce the ordinary argument error only if the call executes. `ctx.tools.executionMode(exec)` resolves the live tool definition and returns the tagged `parallel` or `exclusive` mode; unknown tools fail closed to exclusive.
|
`defineTool()` validates arguments before invoking a typed classifier. Invalid arguments classify as exclusive and produce the ordinary argument error only if the call executes. `ctx.tools.executionMode(exec)` resolves the live tool definition and returns the tagged `parallel` or `exclusive` mode; unknown tools fail closed to exclusive.
|
||||||
|
|
||||||
A tagged mode, rather than a public boolean scheduler API, leaves room for a future resource-aware mode without changing the classifier contract.
|
A tagged mode, rather than a public boolean scheduler API, keeps resource-aware variants representable without changing the classifier contract.
|
||||||
|
|
||||||
## Scheduling and ordering
|
## Scheduling and ordering
|
||||||
|
|
||||||
@@ -58,7 +58,7 @@ Any shared state touched during execution must be concurrency-safe. This include
|
|||||||
|
|
||||||
`maxParallelToolCalls` is a positive AgentLoop deployment cap shared by every agent the factory creates. It defaults to `10`; `1` preserves serial execution. Exact fields and defaults live in the generated [configuration catalog](../../../config-catalog.md).
|
`maxParallelToolCalls` is a positive AgentLoop deployment cap shared by every agent the factory creates. It defaults to `10`; `1` preserves serial execution. Exact fields and defaults live in the generated [configuration catalog](../../../config-catalog.md).
|
||||||
|
|
||||||
The shipped declarations are conservative. Web search, web fetch, and filesystem read opt in. Filesystem writes and edits, bash tools, subagent delegation, workflow, user interaction, todo mutation, Code Mode, and Cordis mutation tools remain exclusive. A subagent may share its parent's workspace or external resources, and the unary classifier cannot prove that sibling delegations have disjoint effects. Bash stays exclusive until its owning package supplies a proven input-sensitive classifier.
|
The shipped declarations are conservative. Web search, web fetch, and filesystem read opt in. Filesystem writes and edits, bash tools, subagent delegation, workflow, user interaction, todo mutation, Code Mode, and Cordis mutation tools remain exclusive. A subagent may share its parent's workspace or external resources, and the unary classifier cannot prove that sibling delegations have disjoint effects. Bash has no proven input-sensitive classifier and remains exclusive.
|
||||||
|
|
||||||
Filesystem read relies on a narrow recorder exception: its synchronous observation updates may settle out of order, but write and edit re-check the observed version before mutation, so stale state only produces `FS_STALE_VERSION`.
|
Filesystem read relies on a narrow recorder exception: its synchronous observation updates may settle out of order, but write and edit re-check the observed version before mutation, so stale state only produces `FS_STALE_VERSION`.
|
||||||
|
|
||||||
@@ -80,7 +80,7 @@ Snapshot coverage pins the visible multi-call transcript: pending calls may over
|
|||||||
|
|
||||||
**Parallelize the complete tool pipeline.** This keeps the loop on the public one-call API but runs pre- and post-execute middleware concurrently. Existing guards and hook bridges may carry ordered state, so only dispatch overlaps.
|
**Parallelize the complete tool pipeline.** This keeps the loop on the public one-call API but runs pre- and post-execute middleware concurrently. Existing guards and hook bridges may carry ordered state, so only dispatch overlaps.
|
||||||
|
|
||||||
**Expose staged methods or a scheduling waterfall.** Public `prepare` / `dispatch` / `finalize` methods or a `tools/execution-mode` event add extension surface before another consumer needs it. The loop uses an internal scheduler view, while `executionMode(exec)` remains the insertion point for a future policy seam.
|
**Expose staged methods or a scheduling waterfall.** Public `prepare` / `dispatch` / `finalize` methods or a `tools/execution-mode` event add extension surface before another consumer needs it. The loop uses an internal scheduler view, while `executionMode(exec)` leaves an insertion point for a policy seam.
|
||||||
|
|
||||||
**Start calls while the model streams.** This may reduce latency further but changes assistant-message authority, replay, and call/result pairing. The scheduler starts only after the assistant message is complete.
|
**Start calls while the model streams.** This may reduce latency further but changes assistant-message authority, replay, and call/result pairing. The scheduler starts only after the assistant message is complete.
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ The config-driven `ctx.agentLoop.create()` path keeps its agent owned by the loo
|
|||||||
|
|
||||||
```ts
|
```ts
|
||||||
interface Config {
|
interface Config {
|
||||||
maxParallelToolCalls?: number // shared by every agent; default 10; 1 is serial
|
maxParallelToolCalls?: number // default 10; 1 is serial
|
||||||
agents: Array<{
|
agents: Array<{
|
||||||
id: string // required
|
id: string // required
|
||||||
provider?: string
|
provider?: string
|
||||||
@@ -54,7 +54,7 @@ The driver owns one agent for its lifetime. It records turn, step, request, stre
|
|||||||
|
|
||||||
Plugin failure ends the current turn, not the loop. Cancellation clears pending work and aborts the current step without leaking to the next prompt. Terminal continuation stops remain authoritative through turn close and durability flush.
|
Plugin failure ends the current turn, not the loop. Cancellation clears pending work and aborts the current step without leaking to the next prompt. Terminal continuation stops remain authoritative through turn close and durability flush.
|
||||||
|
|
||||||
Within a step, consecutive parallel-safe calls form a rolling-pool group; exclusive calls are ordering barriers. The scheduler reclassifies pending calls after each barrier and before replenishing the pool, so a live tool-registry change applies before the next call starts. Only dispatch/body overlaps. Pre/post policy, durable results, and additional context remain in model order. Abort stops replenishment, drains started calls, drops their buffered context, and ends the turn through the normal abort path.
|
Within a step, exclusive calls form barriers; parallel-safe calls use a bounded rolling pool and are reclassified before start. Only dispatch/body overlaps. Policy, durable results, and context remain model-ordered. Abort stops new calls, drains started results, discards their context, and follows the normal abort path.
|
||||||
|
|
||||||
### What belongs to plugins
|
### What belongs to plugins
|
||||||
|
|
||||||
@@ -82,7 +82,7 @@ Everything that goes beyond "call the model, run the tools, repeat" belongs to p
|
|||||||
|
|
||||||
## Known Limitations and Deferred Work
|
## Known Limitations and Deferred Work
|
||||||
|
|
||||||
- **Concurrency is explicit and conservative** — only tools whose per-call classifier returns `true` join the rolling pool; undeclared, invalid, or throwing classifications remain exclusive.
|
- **Classification is unary** — calls whose safety depends on comparing siblings or resources must remain exclusive ([rationale](../../../docs/rfc/implemented/feature/2026-07-10-parallel-tool-call-execution.md)).
|
||||||
- **No resume-or-create policy on the config path** — config-driven `create()` starts a fresh `${id}-session-<uuid>` every run (`TODO(demo)`), and a config `resumeSessionId` whose resume fails logs a warning and creates no agent.
|
- **No resume-or-create policy on the config path** — config-driven `create()` starts a fresh `${id}-session-<uuid>` every run (`TODO(demo)`), and a config `resumeSessionId` whose resume fails logs a warning and creates no agent.
|
||||||
- **Config agents have no per-agent persona field or setup hook** — they use the deployment persona; scoped persona/tool composition is available only through the programmatic `ctx.agents.create()` / `resume()` factory options.
|
- **Config agents have no per-agent persona field or setup hook** — they use the deployment persona; scoped persona/tool composition is available only through the programmatic `ctx.agents.create()` / `resume()` factory options.
|
||||||
- **No built-in turn budget** — the default continuation is `continue` whenever a step had tool calls or steering; bounding a runaway turn requires an `agent/turn-continuation` force-stop plugin.
|
- **No built-in turn budget** — the default continuation is `continue` whenever a step had tool calls or steering; bounding a runaway turn requires an `agent/turn-continuation` force-stop plugin.
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export interface PreparedReactLoopAgent {
|
|||||||
* @param id - the concrete agent identity.
|
* @param id - the concrete agent identity.
|
||||||
* @param options - loop options for the agent.
|
* @param options - loop options for the agent.
|
||||||
* @param session - the prepared session the agent will own.
|
* @param session - the prepared session the agent will own.
|
||||||
* @param maxParallelToolCalls - resolved scheduler cap shared by this factory's agents.
|
* @param maxParallelToolCalls - resolved in-flight cap for this agent.
|
||||||
* @returns the agent and closures bound only to that exact instance.
|
* @returns the agent and closures bound only to that exact instance.
|
||||||
*/
|
*/
|
||||||
export function prepareReactLoopAgent(
|
export function prepareReactLoopAgent(
|
||||||
@@ -144,7 +144,7 @@ export class ReactLoopAgent implements Agent {
|
|||||||
* the `disposed` transition fires and leave the promise hanging.
|
* the `disposed` transition fires and leave the promise hanging.
|
||||||
*/
|
*/
|
||||||
private idleWaiters: (() => void)[] = []
|
private idleWaiters: (() => void)[] = []
|
||||||
/** Immutable scheduler cap resolved by the owning AgentLoop factory. */
|
/** Maximum parallel-safe calls allowed in one step. */
|
||||||
private readonly maxParallelToolCalls: number
|
private readonly maxParallelToolCalls: number
|
||||||
/**
|
/**
|
||||||
* Durability checkpoints started by idle {@link inject} calls. `inject()` is
|
* Durability checkpoints started by idle {@link inject} calls. `inject()` is
|
||||||
|
|||||||
@@ -1,15 +1,6 @@
|
|||||||
/**
|
/** Shared agent-loop scheduler defaults.
|
||||||
* Loop-level tunable defaults shared between the plugin entry (`index.ts`) and
|
|
||||||
* the tool-call scheduler (`tool-calls.ts`). Kept in a leaf module so importing
|
|
||||||
* a default never pulls in the service class or the scheduler.
|
|
||||||
*
|
|
||||||
* @module dsh-agent-loop/constants
|
* @module dsh-agent-loop/constants
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/** Default maximum in-flight parallel-safe calls per agent step. */
|
||||||
* Default cap on simultaneously in-flight tool calls within one assistant step
|
|
||||||
* when the agent-loop config omits one. Matches the rolling-pool size Claude
|
|
||||||
* Code uses; a larger group is not truncated — the cap limits concurrency, not
|
|
||||||
* the group.
|
|
||||||
*/
|
|
||||||
export const DEFAULT_MAX_PARALLEL_TOOL_CALLS = 10
|
export const DEFAULT_MAX_PARALLEL_TOOL_CALLS = 10
|
||||||
|
|||||||
@@ -333,9 +333,8 @@ export { DEFAULT_MAX_PARALLEL_TOOL_CALLS }
|
|||||||
/** Agent-loop plugin configuration. */
|
/** Agent-loop plugin configuration. */
|
||||||
export interface Config {
|
export interface Config {
|
||||||
/**
|
/**
|
||||||
* Concurrent parallel-safe tool-call cap shared by every agent this factory
|
* Maximum parallel-safe calls in flight per agent step. `1` is serial;
|
||||||
* creates. A positive integer; `1` preserves fully serial execution and an
|
* omission defaults to {@link DEFAULT_MAX_PARALLEL_TOOL_CALLS}.
|
||||||
* omitted value defaults to {@link DEFAULT_MAX_PARALLEL_TOOL_CALLS}.
|
|
||||||
*/
|
*/
|
||||||
maxParallelToolCalls?: number
|
maxParallelToolCalls?: number
|
||||||
/** Agents created or resumed at plugin startup. */
|
/** Agents created or resumed at plugin startup. */
|
||||||
@@ -355,7 +354,6 @@ export class AgentLoop extends Service implements AgentFactory {
|
|||||||
|
|
||||||
/** Runtime schema for declarative agents. */
|
/** Runtime schema for declarative agents. */
|
||||||
static Config = z.object({
|
static Config = z.object({
|
||||||
// The deployment-wide cap is defaulted and validated at plugin load.
|
|
||||||
maxParallelToolCalls: z.number().step(1).min(1).default(DEFAULT_MAX_PARALLEL_TOOL_CALLS),
|
maxParallelToolCalls: z.number().step(1).min(1).default(DEFAULT_MAX_PARALLEL_TOOL_CALLS),
|
||||||
agents: z.array(z.object({
|
agents: z.array(z.object({
|
||||||
id: z.string().required(),
|
id: z.string().required(),
|
||||||
@@ -367,7 +365,7 @@ export class AgentLoop extends Service implements AgentFactory {
|
|||||||
}) as unknown as z<Config>
|
}) as unknown as z<Config>
|
||||||
|
|
||||||
private readonly ownership: FactoryOwnership
|
private readonly ownership: FactoryOwnership
|
||||||
/** Resolved immutable scheduler cap shared by every driver from this factory. */
|
/** Resolved concurrency cap for every driver created by this factory. */
|
||||||
private readonly maxParallelToolCalls: number
|
private readonly maxParallelToolCalls: number
|
||||||
/** Plain holder prevents Cordis from re-tracing the factory's dependency context through a caller shadow. */
|
/** Plain holder prevents Cordis from re-tracing the factory's dependency context through a caller shadow. */
|
||||||
private readonly runtime: { ctx: Context }
|
private readonly runtime: { ctx: Context }
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ function stepFinishReason(finish: FinishReason): TurnEndReason | undefined {
|
|||||||
export interface LoopHandle {
|
export interface LoopHandle {
|
||||||
/** Native-private agent inbox handed to the driver only at internal startup. */
|
/** Native-private agent inbox handed to the driver only at internal startup. */
|
||||||
readonly inbox: Inbox
|
readonly inbox: Inbox
|
||||||
/** Immutable concurrent tool-call cap resolved by the owning factory. */
|
/** Maximum parallel-safe calls allowed in one step. */
|
||||||
readonly maxParallelToolCalls: number
|
readonly maxParallelToolCalls: number
|
||||||
setStatus(status: 'idle' | 'running'): void
|
setStatus(status: 'idle' | 'running'): void
|
||||||
setAbort(controller: AbortController | undefined): void
|
setAbort(controller: AbortController | undefined): void
|
||||||
@@ -558,13 +558,12 @@ async function runStep(
|
|||||||
// Empty messages exist only to carry usage; the helper also omits empty chunk provenance.
|
// Empty messages exist only to carry usage; the helper also omits empty chunk provenance.
|
||||||
recordAssistantMessage(session, turn, step, header.config, assembledContent, message, assembler, chunkSeqs)
|
recordAssistantMessage(session, turn, step, header.config, assembledContent, message, assembler, chunkSeqs)
|
||||||
|
|
||||||
// The scheduler overlaps only dispatch/body for parallel-safe calls; policy,
|
// Dispatch may overlap; policy, results, and context remain model-ordered.
|
||||||
// results, and additional context remain in model order.
|
|
||||||
const pendingContext = toolCalls.length > 0
|
const pendingContext = toolCalls.length > 0
|
||||||
? await executeToolCalls(ctx, agent, turn, step, toolCalls, signal, maxParallelToolCalls)
|
? await executeToolCalls(ctx, agent, turn, step, toolCalls, signal, maxParallelToolCalls)
|
||||||
: []
|
: []
|
||||||
|
|
||||||
// Append context after the complete result batch to preserve call/result adjacency.
|
// Context follows the complete result batch to preserve call/result adjacency.
|
||||||
for (const context of pendingContext) {
|
for (const context of pendingContext) {
|
||||||
agent.inject(context.content, {
|
agent.inject(context.content, {
|
||||||
source: context.source,
|
source: context.source,
|
||||||
|
|||||||
@@ -1,22 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* The agent loop's per-step tool-call scheduler. `runStep` (loop.ts) hands it
|
* Schedules one assistant step's tool calls. Exclusive calls form barriers;
|
||||||
* the assistant message's `tool-call` blocks; this module parses each call's
|
* parallel calls use a bounded rolling pool and are reclassified before start.
|
||||||
* arguments once, classifies pending calls via `ctx.tools.executionMode`, and
|
* Dispatch may overlap, while policy, results, and context remain model-ordered.
|
||||||
* runs ordered groups through a rolling pool bounded by the agent-loop's
|
* Abort stops replenishment and drains started calls.
|
||||||
* `maxParallelToolCalls` config. Exclusive calls are singleton barriers. A
|
|
||||||
* parallel group reclassifies each later call before it starts, so registry
|
|
||||||
* changes during an earlier barrier or ordered result commit take effect before
|
|
||||||
* the pool replenishes.
|
|
||||||
*
|
|
||||||
* The session log stays the source of truth and is reconstructable regardless
|
|
||||||
* of dispatch timing: each STARTED call appends its own `tool/call` before its
|
|
||||||
* body runs, `tool/result` events are appended in MODEL order (slot-buffered
|
|
||||||
* behind a commit cursor), and buffered `additionalContexts` are injected in model
|
|
||||||
* call order after every result. A `tool/call`'s log position may interleave
|
|
||||||
* with a sibling's `tool/result` as the pool replenishes; that is safe because
|
|
||||||
* `tool/call` is log-only and derived history pairs the assistant message's
|
|
||||||
* `tool-call` blocks with the ordered `tool/result`s by `callId`.
|
|
||||||
*
|
*
|
||||||
|
* Each started call records `tool/call`; `tool/result` commits in model order,
|
||||||
|
* preserving derived history when audit events interleave with earlier results.
|
||||||
* @module dsh-agent-loop/tool-calls
|
* @module dsh-agent-loop/tool-calls
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -29,40 +18,30 @@ import type { ReactLoopAgent } from './agent.ts'
|
|||||||
|
|
||||||
/** One tool call after argument parsing, ready to schedule. */
|
/** One tool call after argument parsing, ready to schedule. */
|
||||||
interface PlannedCall {
|
interface PlannedCall {
|
||||||
/** The model-transcript call (authoritative `id`/`name`/raw `arguments`). */
|
|
||||||
block: ToolCallBlock
|
block: ToolCallBlock
|
||||||
/** The distinct per-call execution input handed to the tool pipeline. */
|
|
||||||
exec: ToolExecutionInput
|
exec: ToolExecutionInput
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A settled call's slot, filled in model order before ordered finalization. */
|
/** Settled dispatch awaiting model-order finalization. */
|
||||||
interface Slot {
|
interface Slot {
|
||||||
/** The registry-minted execution object, carrying this call's token. */
|
|
||||||
exec: ToolRunContext
|
exec: ToolRunContext
|
||||||
/** The raw dispatch/pre result. */
|
|
||||||
result: ToolExecutionResult
|
result: ToolExecutionResult
|
||||||
/** Whether the result still needs ordered `tools/post-execute` finalization. */
|
|
||||||
needsPost: boolean
|
needsPost: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute one assistant step's tool calls, honoring per-call concurrency safety.
|
* Schedule one assistant step's tool calls by their live concurrency mode.
|
||||||
|
* Started calls receive ordered results; abort drains them, discards their
|
||||||
|
* buffered context, and rethrows so the turn owns final error handling.
|
||||||
*
|
*
|
||||||
* Appends `tool/call` (per started call) and `tool/result` (in model order) to
|
* @param ctx - loop context that owns the tool registry.
|
||||||
* the session, and returns the ordered `additionalContexts` buffer for the loop
|
* @param agent - agent and session receiving the call lifecycle.
|
||||||
* to inject after the batch. On abort it drains only already-started calls to
|
* @param turn - current turn number.
|
||||||
* results, drops buffered context, and throws the abort error so `runTurn` owns
|
* @param step - current step number.
|
||||||
* the turn-end reason.
|
* @param toolCalls - assistant calls in model order.
|
||||||
*
|
* @param signal - abort signal shared by the step.
|
||||||
* @param ctx - the loop context (reaches `ctx.tools`).
|
* @param maxParallel - validated in-flight cap.
|
||||||
* @param agent - the agent being driven (owns the session, options, and is
|
* @returns buffered contexts in model call order.
|
||||||
* passed to each `ToolExecution`).
|
|
||||||
* @param turn - the current turn number (for the session events).
|
|
||||||
* @param step - the current step number (for the session events).
|
|
||||||
* @param toolCalls - the assistant message's `tool-call` blocks, in model order.
|
|
||||||
* @param signal - the step's abort signal (shared by every call).
|
|
||||||
* @param maxParallel - the already-validated cap snapshot for parallel groups.
|
|
||||||
* @returns the per-step `additionalContexts` buffer in model call order.
|
|
||||||
*/
|
*/
|
||||||
export async function executeToolCalls(
|
export async function executeToolCalls(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
@@ -75,10 +54,7 @@ export async function executeToolCalls(
|
|||||||
): Promise<HookContext[]> {
|
): Promise<HookContext[]> {
|
||||||
const { session } = agent
|
const { session } = agent
|
||||||
|
|
||||||
// Plan: parse each call's raw JSON arguments exactly once, and build one
|
// Inputs are distinct because tools/execute wrappers may replace `exec.signal`.
|
||||||
// distinct ToolExecution per call so a `tools/execute` wrapper that mutates
|
|
||||||
// `exec` in place (e.g. replacing exec.signal with a per-call deadline) cannot
|
|
||||||
// race through a shared payload.
|
|
||||||
const planned: PlannedCall[] = toolCalls.map(block => ({
|
const planned: PlannedCall[] = toolCalls.map(block => ({
|
||||||
block,
|
block,
|
||||||
exec: {
|
exec: {
|
||||||
@@ -93,9 +69,7 @@ export async function executeToolCalls(
|
|||||||
const pendingContext: HookContext[] = []
|
const pendingContext: HookContext[] = []
|
||||||
let next = 0
|
let next = 0
|
||||||
while (next < planned.length) {
|
while (next < planned.length) {
|
||||||
// Classify the next group only after the previous one has fully committed.
|
// Commit before classifying again so registry changes affect unstarted calls.
|
||||||
// A registry mutation in an exclusive call or result observer therefore
|
|
||||||
// changes how every not-yet-started call is scheduled.
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded by the loop condition
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded by the loop condition
|
||||||
const first = planned[next]!
|
const first = planned[next]!
|
||||||
const mode = ctx.tools.executionMode(first.exec).kind
|
const mode = ctx.tools.executionMode(first.exec).kind
|
||||||
@@ -105,7 +79,7 @@ export async function executeToolCalls(
|
|||||||
return pendingContext
|
return pendingContext
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Parse a model-produced raw arguments string, falling back to the raw string on invalid JSON (empty ⇒ `{}`). */
|
/** Parse model arguments, preserving invalid JSON as text and mapping empty input to `{}`. */
|
||||||
function parseArguments(raw: string): unknown {
|
function parseArguments(raw: string): unknown {
|
||||||
try {
|
try {
|
||||||
return raw ? JSON.parse(raw) : {}
|
return raw ? JSON.parse(raw) : {}
|
||||||
@@ -115,18 +89,11 @@ function parseArguments(raw: string): unknown {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The rolling-pool path for one ordered group. A singleton exclusive group runs
|
* Run one exclusive barrier or parallel pool. Later calls are reclassified
|
||||||
* as a pool of one (a barrier). A parallel-safe run starts calls in model order
|
* before start; an exclusive reclassification waits for the current pool to
|
||||||
* up to `maxParallel`; before each later call starts, the scheduler reclassifies
|
* drain and remains for the caller's next barrier. Results and contexts commit
|
||||||
* it against the live registry. An exclusive result stops replenishment, drains
|
* in model order. Abort stops starts, drains and commits started calls, discards
|
||||||
* the current run, and remains for the caller's next singleton group. Settled
|
* their contexts, and throws.
|
||||||
* dispatches land in model-order slots; a commit cursor appends `tool/result`
|
|
||||||
* (and collects `additionalContexts`) only while the next slot is ready, so the
|
|
||||||
* log stays model-ordered regardless of completion order.
|
|
||||||
*
|
|
||||||
* Abort: an already-aborted signal starts nothing and throws before any
|
|
||||||
* `tool/call`. An abort mid-group stops replenishment, awaits only the started
|
|
||||||
* calls, commits their results in order, drops buffered context, and throws.
|
|
||||||
*/
|
*/
|
||||||
async function runGroup(
|
async function runGroup(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
@@ -142,17 +109,14 @@ async function runGroup(
|
|||||||
/* v8 ignore next -- signal.reason always set: cancel()/disposal provide a default */
|
/* v8 ignore next -- signal.reason always set: cancel()/disposal provide a default */
|
||||||
if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted'))
|
if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted'))
|
||||||
const slots: (Slot | undefined)[] = group.map(() => undefined)
|
const slots: (Slot | undefined)[] = group.map(() => undefined)
|
||||||
// callSeqs[i] is the `tool/call` event seq for started slot i (its provenance
|
// Started slots retain their tool/call seq for result provenance.
|
||||||
// for the matching tool/result). A slot is only committed after it is started,
|
|
||||||
// so its callSeq is always set by then.
|
|
||||||
const callSeqs: number[] = group.map(() => -1)
|
const callSeqs: number[] = group.map(() => -1)
|
||||||
let nextToStart = 0
|
let nextToStart = 0
|
||||||
let committed = 0
|
let committed = 0
|
||||||
let started = 0
|
let started = 0
|
||||||
let aborted: boolean = signal.aborted
|
let aborted: boolean = signal.aborted
|
||||||
|
|
||||||
// Advance the commit cursor over contiguous settled slots: run post-execute in
|
// `committed` advances only across contiguous model-order slots.
|
||||||
// model order, append each tool/result, and collect its additionalContexts.
|
|
||||||
const commitReady = async (): Promise<void> => {
|
const commitReady = async (): Promise<void> => {
|
||||||
while (committed < group.length) {
|
while (committed < group.length) {
|
||||||
const slot = slots[committed]
|
const slot = slots[committed]
|
||||||
@@ -161,7 +125,6 @@ async function runGroup(
|
|||||||
const result = slot.needsPost
|
const result = slot.needsPost
|
||||||
? await ctx.tools[TOOL_REGISTRY_SCHEDULER].finalize(slot.exec, slot.result)
|
? await ctx.tools[TOOL_REGISTRY_SCHEDULER].finalize(slot.exec, slot.result)
|
||||||
: ctx.tools[TOOL_REGISTRY_SCHEDULER].finish(slot.exec, slot.result)
|
: ctx.tools[TOOL_REGISTRY_SCHEDULER].finish(slot.exec, slot.result)
|
||||||
// committed < group.length, so call and its callSeq (set at start) exist.
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded index
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded index
|
||||||
appendToolResult(session, turn, step, call!.block, result, callSeqs[committed]!)
|
appendToolResult(session, turn, step, call!.block, result, callSeqs[committed]!)
|
||||||
pendingContext.push(...result.additionalContexts ?? [])
|
pendingContext.push(...result.additionalContexts ?? [])
|
||||||
@@ -172,7 +135,6 @@ async function runGroup(
|
|||||||
const inFlight = new Map<number, Promise<number>>()
|
const inFlight = new Map<number, Promise<number>>()
|
||||||
|
|
||||||
const startCall = async (index: number): Promise<void> => {
|
const startCall = async (index: number): Promise<void> => {
|
||||||
// index is always < group.length (bounded by every caller).
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded index
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded index
|
||||||
const call = group[index]!
|
const call = group[index]!
|
||||||
callSeqs[index] = appendToolCall(session, turn, step, call.block)
|
callSeqs[index] = appendToolCall(session, turn, step, call.block)
|
||||||
@@ -201,9 +163,7 @@ async function runGroup(
|
|||||||
|
|
||||||
const fillPool = async (): Promise<void> => {
|
const fillPool = async (): Promise<void> => {
|
||||||
while (!aborted && nextToStart < group.length && inFlight.size < maxParallel) {
|
while (!aborted && nextToStart < group.length && inFlight.size < maxParallel) {
|
||||||
// The caller classified the first item immediately before entering this
|
// Re-read later modes after ordered commits so registry changes can create a barrier.
|
||||||
// group. Re-read every later item after ordered commits so a live registry
|
|
||||||
// change can turn it into the next barrier.
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded by the loop condition
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded by the loop condition
|
||||||
const nextCall = group[nextToStart]!
|
const nextCall = group[nextToStart]!
|
||||||
if (nextToStart > 0 && mode === 'parallel'
|
if (nextToStart > 0 && mode === 'parallel'
|
||||||
@@ -211,49 +171,40 @@ async function runGroup(
|
|||||||
await startCall(nextToStart)
|
await startCall(nextToStart)
|
||||||
nextToStart++
|
nextToStart++
|
||||||
await commitReady()
|
await commitReady()
|
||||||
// The signal CAN flip while an ordered pre-execute listener is running.
|
// Abort may arrive while pre-execute awaits.
|
||||||
if (signal.aborted) aborted = true
|
if (signal.aborted) aborted = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prime the pool up to the cap. Ordered pre-execute listeners may be async;
|
// Ordered pre-execute may await; only dispatch/body overlaps.
|
||||||
// dispatch/body is the only stage that overlaps across in-flight calls.
|
|
||||||
await fillPool()
|
await fillPool()
|
||||||
while (inFlight.size > 0) {
|
while (inFlight.size > 0) {
|
||||||
const settledIndex = await Promise.race(inFlight.values())
|
const settledIndex = await Promise.race(inFlight.values())
|
||||||
inFlight.delete(settledIndex)
|
inFlight.delete(settledIndex)
|
||||||
// Commit every contiguous settled slot now available.
|
|
||||||
await commitReady()
|
await commitReady()
|
||||||
// The signal CAN flip during the await above (abort() inside a tool); the
|
// Abort may arrive while a tool or ordered commit awaits.
|
||||||
// analyzer can't see through the await boundary. An abort stops the pool
|
|
||||||
// from starting any further calls, but already-started calls still drain.
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||||
if (signal.aborted) aborted = true
|
if (signal.aborted) aborted = true
|
||||||
await fillPool()
|
await fillPool()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (aborted) {
|
if (aborted) {
|
||||||
// Every started call has settled and committed in order; buffered context
|
// Started calls are committed; their context is discarded with the aborted step.
|
||||||
// from this aborted step is dropped (not injected). Raise the abort so the
|
|
||||||
// existing runTurn catch owns turn/end reason selection. Unstarted calls
|
|
||||||
// beyond the cap never appended a tool/call.
|
|
||||||
/* v8 ignore next -- signal.reason always set: cancel()/disposal provide a default */
|
/* v8 ignore next -- signal.reason always set: cancel()/disposal provide a default */
|
||||||
throw new Error(String(signal.reason ?? 'aborted'))
|
throw new Error(String(signal.reason ?? 'aborted'))
|
||||||
}
|
}
|
||||||
// A defensive check that every started call committed before this group
|
|
||||||
// returns; a reclassified barrier may leave the rest of `group` unstarted.
|
|
||||||
/* v8 ignore next -- unreachable: a non-aborted group commits every started call */
|
/* v8 ignore next -- unreachable: a non-aborted group commits every started call */
|
||||||
if (committed !== started) throw new Error('tool-call scheduler: uncommitted settled calls')
|
if (committed !== started) throw new Error('tool-call scheduler: uncommitted settled calls')
|
||||||
return started
|
return started
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Append the `tool/call` audit event for one started call; returns its seq (the tool/result's provenance). */
|
/** Append a started call and return its provenance sequence. */
|
||||||
function appendToolCall(session: Session, turn: number, step: number, block: ToolCallBlock): number {
|
function appendToolCall(session: Session, turn: number, step: number, block: ToolCallBlock): number {
|
||||||
const event = session.append('tool/call', { turn, step, callId: block.id, name: block.name, arguments: block.arguments })
|
const event = session.append('tool/call', { turn, step, callId: block.id, name: block.name, arguments: block.arguments })
|
||||||
return event.seq
|
return event.seq
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Append one call's `tool/result`, keyed by the authoritative model-transcript call id and provenanced to its `tool/call`. */
|
/** Append a model-ordered result linked to its call event. */
|
||||||
function appendToolResult(
|
function appendToolResult(
|
||||||
session: Session,
|
session: Session,
|
||||||
turn: number,
|
turn: number,
|
||||||
|
|||||||
@@ -1,13 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* The per-step tool-call scheduler (`tool-calls.ts`): live classification by
|
* Exercises scheduler ordering and cancellation with deterministic gated tools.
|
||||||
* `ctx.tools.executionMode`, the rolling pool for parallel groups, model-order
|
* ACP goldens own transcript-facing coverage.
|
||||||
* `tool/result` commit despite out-of-order settlement, registry-change
|
|
||||||
* reclassification, interleaved `tool/call` audit records, ordered
|
|
||||||
* `tools/pre-execute`/`tools/post-execute`, model-ordered `additionalContexts`,
|
|
||||||
* and abort behavior.
|
|
||||||
*
|
|
||||||
* Tools are mocked and deterministic — no real API, no snapshot here (the
|
|
||||||
* transcript-facing live-order behavior is pinned by the ACP snapshot goldens).
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
@@ -48,7 +41,7 @@ function events(agent: ReactLoopAgent): SessionEvent[] {
|
|||||||
return [...agent.session.events]
|
return [...agent.session.events]
|
||||||
}
|
}
|
||||||
|
|
||||||
/** An assistant message with N tool-call blocks named `name` (ids c1..cN, arg = index). */
|
/** Build one assistant response containing the supplied tool calls. */
|
||||||
function multiCall(calls: { id: string; name: string; args: object }[]): StreamChunk[] {
|
function multiCall(calls: { id: string; name: string; args: object }[]): StreamChunk[] {
|
||||||
const chunks: StreamChunk[] = []
|
const chunks: StreamChunk[] = []
|
||||||
calls.forEach((call, index) => {
|
calls.forEach((call, index) => {
|
||||||
@@ -82,18 +75,15 @@ function gatedTool(name: string, parallel: boolean) {
|
|||||||
return {
|
return {
|
||||||
tool,
|
tool,
|
||||||
started,
|
started,
|
||||||
/** Release one in-flight call by its arg id (its `execute` resolves). */
|
|
||||||
release(id: string) { gates.get(id)?.(); gates.delete(id) },
|
release(id: string) { gates.get(id)?.(); gates.delete(id) },
|
||||||
pending() { return [...gates.keys()] },
|
pending() { return [...gates.keys()] },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A parallel-safe gated tool. */
|
|
||||||
function gatedParallelTool(name: string) {
|
function gatedParallelTool(name: string) {
|
||||||
return gatedTool(name, true)
|
return gatedTool(name, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** An exclusive gated tool. */
|
|
||||||
function gatedExclusiveTool(name: string) {
|
function gatedExclusiveTool(name: string) {
|
||||||
return gatedTool(name, false)
|
return gatedTool(name, false)
|
||||||
}
|
}
|
||||||
@@ -116,7 +106,6 @@ describe('tool-call scheduler: grouping and barriers', () => {
|
|||||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||||
|
|
||||||
agent.send([{ type: 'text', text: 'go' }])
|
agent.send([{ type: 'text', text: 'go' }])
|
||||||
// All three start before any is released — proof of concurrency.
|
|
||||||
await until(() => gated.started.length === 3)
|
await until(() => gated.started.length === 3)
|
||||||
expect(gated.started).toEqual(['1', '2', '3'])
|
expect(gated.started).toEqual(['1', '2', '3'])
|
||||||
gated.release('1'); gated.release('2'); gated.release('3')
|
gated.release('1'); gated.release('2'); gated.release('3')
|
||||||
@@ -124,9 +113,6 @@ describe('tool-call scheduler: grouping and barriers', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('an exclusive call between two parallel-safe calls forms a barrier (3 groups)', async () => {
|
it('an exclusive call between two parallel-safe calls forms a barrier (3 groups)', async () => {
|
||||||
// read A (safe), write A (exclusive), read A (safe) → the write must not
|
|
||||||
// overlap either read. The exclusive tool records whether a read was still
|
|
||||||
// in flight when it ran.
|
|
||||||
const order: string[] = []
|
const order: string[] = []
|
||||||
const adapter = new MockAdapter([
|
const adapter = new MockAdapter([
|
||||||
multiCall([
|
multiCall([
|
||||||
@@ -150,7 +136,6 @@ describe('tool-call scheduler: grouping and barriers', () => {
|
|||||||
agent.send([{ type: 'text', text: 'go' }])
|
agent.send([{ type: 'text', text: 'go' }])
|
||||||
await waitForIdle(ctx, agent)
|
await waitForIdle(ctx, agent)
|
||||||
|
|
||||||
// The write ran strictly between the two reads (barrier ordering).
|
|
||||||
expect(order).toEqual(['r-start-A1', 'r-end-A1', 'w-A2', 'r-start-A3', 'r-end-A3'])
|
expect(order).toEqual(['r-start-A1', 'r-end-A1', 'w-A2', 'r-start-A3', 'r-end-A3'])
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -243,8 +228,6 @@ describe('tool-call scheduler: model-order results despite out-of-order settleme
|
|||||||
|
|
||||||
agent.send([{ type: 'text', text: 'go' }])
|
agent.send([{ type: 'text', text: 'go' }])
|
||||||
await until(() => gated.started.length === 2)
|
await until(() => gated.started.length === 2)
|
||||||
// Release the SECOND call first; its result must NOT be committed until the
|
|
||||||
// first commits (the commit cursor holds it in a slot).
|
|
||||||
gated.release('2')
|
gated.release('2')
|
||||||
await new Promise(r => setTimeout(r, 5))
|
await new Promise(r => setTimeout(r, 5))
|
||||||
const beforeFirst = events(agent).filter(e => e.type === 'tool/result')
|
const beforeFirst = events(agent).filter(e => e.type === 'tool/result')
|
||||||
@@ -270,8 +253,6 @@ describe('tool-call scheduler: model-order results despite out-of-order settleme
|
|||||||
gated.release('2'); gated.release('1')
|
gated.release('2'); gated.release('1')
|
||||||
await waitForIdle(ctx, agent)
|
await waitForIdle(ctx, agent)
|
||||||
|
|
||||||
// deriveMessages pairs the assistant tool-call blocks with tool-result
|
|
||||||
// blocks by callId — model order, independent of log interleaving.
|
|
||||||
const messages = agent.session.deriveMessages()
|
const messages = agent.session.deriveMessages()
|
||||||
const toolResults = messages.flatMap(m => m.content.filter(b => b.type === 'tool-result'))
|
const toolResults = messages.flatMap(m => m.content.filter(b => b.type === 'tool-result'))
|
||||||
expect(toolResults.map(b => b.toolCallId)).toEqual([CallId('c1'), CallId('c2')])
|
expect(toolResults.map(b => b.toolCallId)).toEqual([CallId('c1'), CallId('c2')])
|
||||||
@@ -314,11 +295,9 @@ describe('tool-call scheduler: rolling pool honors maxParallelToolCalls', () =>
|
|||||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||||
|
|
||||||
agent.send([{ type: 'text', text: 'go' }])
|
agent.send([{ type: 'text', text: 'go' }])
|
||||||
// Only 2 start initially (the cap).
|
|
||||||
await until(() => gated.started.length === 2)
|
await until(() => gated.started.length === 2)
|
||||||
await new Promise(r => setTimeout(r, 5))
|
await new Promise(r => setTimeout(r, 5))
|
||||||
expect(gated.started).toEqual(['1', '2'])
|
expect(gated.started).toEqual(['1', '2'])
|
||||||
// Releasing one starts the next in model order.
|
|
||||||
gated.release('1')
|
gated.release('1')
|
||||||
await until(() => gated.started.length === 3)
|
await until(() => gated.started.length === 3)
|
||||||
expect(gated.started).toEqual(['1', '2', '3'])
|
expect(gated.started).toEqual(['1', '2', '3'])
|
||||||
@@ -354,7 +333,7 @@ describe('tool-call scheduler: rolling pool honors maxParallelToolCalls', () =>
|
|||||||
await waitForIdle(ctx, agent)
|
await waitForIdle(ctx, agent)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('applies the global Config cap to every agent created by the factory', async () => {
|
it('applies the configured cap to every factory-created agent', async () => {
|
||||||
const adapter = new MockAdapter([
|
const adapter = new MockAdapter([
|
||||||
multiCall([{ id: 'c1', name: 'p', args: { id: '1' } }, { id: 'c2', name: 'p', args: { id: '2' } }]),
|
multiCall([{ id: 'c1', name: 'p', args: { id: '1' } }, { id: 'c2', name: 'p', args: { id: '2' } }]),
|
||||||
textResponse('done'),
|
textResponse('done'),
|
||||||
@@ -365,7 +344,6 @@ describe('tool-call scheduler: rolling pool honors maxParallelToolCalls', () =>
|
|||||||
await ctx.plugin(SystemPrompt, { persona: '' })
|
await ctx.plugin(SystemPrompt, { persona: '' })
|
||||||
await ctx.plugin(ToolRegistry)
|
await ctx.plugin(ToolRegistry)
|
||||||
await ctx.plugin(AgentRegistry)
|
await ctx.plugin(AgentRegistry)
|
||||||
// The global cap of 1 must serialize every agent from this factory.
|
|
||||||
await ctx.plugin(AgentLoop, { agents: [], maxParallelToolCalls: 1 })
|
await ctx.plugin(AgentLoop, { agents: [], maxParallelToolCalls: 1 })
|
||||||
ctx.llm.registerAdapter(['mock'], adapter)
|
ctx.llm.registerAdapter(['mock'], adapter)
|
||||||
const gated = gatedParallelTool('p')
|
const gated = gatedParallelTool('p')
|
||||||
@@ -400,8 +378,6 @@ describe('tool-call scheduler: ordered middleware and additional contexts', () =
|
|||||||
|
|
||||||
agent.send([{ type: 'text', text: 'go' }])
|
agent.send([{ type: 'text', text: 'go' }])
|
||||||
await until(() => gated.started.length === 3)
|
await until(() => gated.started.length === 3)
|
||||||
// Settle in reverse; post-execute (ordered by the commit cursor) still fires
|
|
||||||
// in model order because post runs on the commit path, not on dispatch.
|
|
||||||
gated.release('3'); gated.release('2'); gated.release('1')
|
gated.release('3'); gated.release('2'); gated.release('1')
|
||||||
await waitForIdle(ctx, agent)
|
await waitForIdle(ctx, agent)
|
||||||
|
|
||||||
@@ -427,7 +403,6 @@ describe('tool-call scheduler: ordered middleware and additional contexts', () =
|
|||||||
await waitForIdle(ctx, agent)
|
await waitForIdle(ctx, agent)
|
||||||
|
|
||||||
const log = events(agent)
|
const log = events(agent)
|
||||||
// Both tool/results precede both context/messages, and context is model-ordered.
|
|
||||||
const contextTexts = log.filter(e => e.type === 'context/message')
|
const contextTexts = log.filter(e => e.type === 'context/message')
|
||||||
.map(e => (e.data.content[0] as { text: string }).text)
|
.map(e => (e.data.content[0] as { text: string }).text)
|
||||||
expect(contextTexts).toEqual(['ctx-c1', 'ctx-c2'])
|
expect(contextTexts).toEqual(['ctx-c1', 'ctx-c2'])
|
||||||
@@ -436,7 +411,7 @@ describe('tool-call scheduler: ordered middleware and additional contexts', () =
|
|||||||
expect(lastResult).toBeLessThan(firstContext)
|
expect(lastResult).toBeLessThan(firstContext)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps pre-produced deny/error results ordered without dispatching those calls', async () => {
|
it('orders pre-execute denials and errors without dispatching them', async () => {
|
||||||
const adapter = new MockAdapter([
|
const adapter = new MockAdapter([
|
||||||
multiCall([
|
multiCall([
|
||||||
{ id: 'c1', name: 'p', args: { id: '1' } },
|
{ id: 'c1', name: 'p', args: { id: '1' } },
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ tools:
|
|||||||
- `ctx.tools.schemas(scope?: ScopeKey): ToolSchema[]` Schemas of everything the scope can see (without the `execute` functions). The shipped tools' schemas are catalogued in [docs/tool-catalog.md](../../../docs/tool-catalog.md), generated by booting each tool plugin and harvesting this method (see [the tool-schema-catalog RFC](../../../docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md)).
|
- `ctx.tools.schemas(scope?: ScopeKey): ToolSchema[]` Schemas of everything the scope can see (without the `execute` functions). The shipped tools' schemas are catalogued in [docs/tool-catalog.md](../../../docs/tool-catalog.md), generated by booting each tool plugin and harvesting this method (see [the tool-schema-catalog RFC](../../../docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md)).
|
||||||
- `ctx.tools.guard(guard: ToolGuard): () => void` Register a monotonic synchronous execution guard after `tools/pre-execute`: returning a reason denies the call, while `undefined` leaves it unchanged. A plain-context guard applies globally; an `agent.ctx` guard applies only to that agent. Later waterfall listeners cannot turn a guard denial back into permission. Disposed with the calling fiber.
|
- `ctx.tools.guard(guard: ToolGuard): () => void` Register a monotonic synchronous execution guard after `tools/pre-execute`: returning a reason denies the call, while `undefined` leaves it unchanged. A plain-context guard applies globally; an `agent.ctx` guard applies only to that agent. Later waterfall listeners cannot turn a guard denial back into permission. Disposed with the calling fiber.
|
||||||
- `ctx.tools.execute(exec)` losslessly snapshots and freezes arguments, assigns an opaque token, runs the complete policy/dispatch/result pipeline, then independently snapshots the authoritative outcome before final observation. Invalid arguments use the same result path without reaching policy or the body; around wrappers may replace only `signal`.
|
- `ctx.tools.execute(exec)` losslessly snapshots and freezes arguments, assigns an opaque token, runs the complete policy/dispatch/result pipeline, then independently snapshots the authoritative outcome before final observation. Invalid arguments use the same result path without reaching policy or the body; around wrappers may replace only `signal`.
|
||||||
- `ctx.tools.executionMode(exec)` returns `parallel` only when the visible definition's `isConcurrencySafe(arguments)` classifier returns exactly `true`; unknown, hidden, undeclared, invalid, or throwing classifications are exclusive.
|
- `ctx.tools.executionMode(exec)` returns `parallel` only when the visible definition's `isConcurrencySafe(exec.arguments)` classifier returns exactly `true`; unknown, hidden, undeclared, invalid, or throwing classifications are exclusive.
|
||||||
|
|
||||||
### Injected services
|
### Injected services
|
||||||
|
|
||||||
@@ -88,7 +88,7 @@ See `defineTool`, `validateArgs`, `ToolArgsError`, `SchemaSpec`, `InferArgs`, an
|
|||||||
|
|
||||||
Optional `timeoutMs` must be positive and finite; it is policy metadata, not model-visible schema.
|
Optional `timeoutMs` must be positive and finite; it is policy metadata, not model-visible schema.
|
||||||
|
|
||||||
Optional `isConcurrencySafe(args)` receives the typed, softly validated argument shape. Returning `true` permits concurrent dispatch/body execution within a step; invalid input and all other outcomes remain exclusive. A safe tool must not mutate parent-owned async state during its body. Ordered returned content, metadata, errors, and post-execute context remain supported; synchronous recorders are safe only when races fail closed, as with filesystem observed-version tracking.
|
Optional `isConcurrencySafe(args)` receives typed, softly validated arguments. Exact `true` permits concurrent dispatch/body execution; invalid input and all other outcomes remain exclusive. Opted-in bodies do not mutate parent-owned state, and shared-state races must commute or fail closed. The [parallel tool-call RFC](../../../docs/rfc/implemented/feature/2026-07-10-parallel-tool-call-execution.md) owns the full safety contract.
|
||||||
|
|
||||||
### Structured-output schema subset
|
### Structured-output schema subset
|
||||||
|
|
||||||
@@ -113,7 +113,7 @@ Under `code` or `both`, the registry exposes the reserved `run_code` transport a
|
|||||||
|
|
||||||
### Parallel execution
|
### Parallel execution
|
||||||
|
|
||||||
The agent loop groups consecutive `parallel` calls into a bounded rolling pool and treats each `exclusive` call as an ordering barrier. Only dispatch/body overlaps; pre/post policy, durable results, and additional context retain model order. `web_search`, `web_fetch`, filesystem `read`, and `subagent` declare conservative safe cases. Mutating filesystem, todo, bash, and `run_code` calls remain exclusive; Code Mode bindings remain serial.
|
The agent loop groups consecutive `parallel` calls into a bounded rolling pool and treats each `exclusive` call as an ordering barrier. Only dispatch/body overlaps; policy, durable results, and context retain model order. Code Mode bindings remain serial. The [parallel tool-call RFC](../../../docs/rfc/implemented/feature/2026-07-10-parallel-tool-call-execution.md) owns the shipped declarations and rationale.
|
||||||
|
|
||||||
## Model Experience
|
## Model Experience
|
||||||
|
|
||||||
|
|||||||
@@ -132,15 +132,16 @@ export interface ToolDefinition extends ToolSchema {
|
|||||||
*/
|
*/
|
||||||
timeoutMs?: number
|
timeoutMs?: number
|
||||||
/**
|
/**
|
||||||
* Pure, synchronous host-only classifier for overlap with sibling tool calls.
|
* Pure synchronous classifier for overlap with sibling tool calls. Only
|
||||||
* Only `true` opts in; omission, exceptions, and invalid `defineTool`
|
* `true` opts in; omission, exceptions, non-`true` returns, and invalid
|
||||||
* arguments are treated as exclusive.
|
* `defineTool` arguments are exclusive. This metadata is never model-visible.
|
||||||
*
|
*
|
||||||
* Opted-in executions must not mutate parent-owned state, and shared state
|
* Opted-in executions must not mutate parent-owned state. Shared state must
|
||||||
* they touch must be concurrency-safe. See the
|
* tolerate concurrent dispatch; recorder races are permitted only when they
|
||||||
|
* commute or fail closed. See the
|
||||||
* [parallel-tool-call RFC](../../../../docs/rfc/implemented/feature/2026-07-10-parallel-tool-call-execution.md)
|
* [parallel-tool-call RFC](../../../../docs/rfc/implemented/feature/2026-07-10-parallel-tool-call-execution.md)
|
||||||
* for the full safety contract and recorder exception.
|
* for the full contract.
|
||||||
* @param args - Parsed tool arguments.
|
* @param args - parsed arguments; `defineTool` validates before calling.
|
||||||
* @returns Whether this call may join a parallel group.
|
* @returns Whether this call may join a parallel group.
|
||||||
*/
|
*/
|
||||||
isConcurrencySafe?(args: unknown): boolean
|
isConcurrencySafe?(args: unknown): boolean
|
||||||
@@ -206,12 +207,8 @@ export interface ToolExecutionInput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How a single tool call may be scheduled relative to its siblings in one
|
* Scheduling mode for one pending call. `parallel` may overlap with siblings;
|
||||||
* assistant step, as decided by {@link ToolRegistry.executionMode}. `parallel`
|
* `exclusive` runs alone and forms an ordering barrier.
|
||||||
* calls may run concurrently within a rolling pool; an `exclusive` call runs
|
|
||||||
* alone and forms an ordering barrier. Object-tagged (rather than a bare
|
|
||||||
* boolean) so a future resource-grouping dimension can extend a variant — e.g.
|
|
||||||
* `{ kind: 'exclusive', group: 'session:...' }` — without a breaking change.
|
|
||||||
*/
|
*/
|
||||||
export type ToolExecutionMode =
|
export type ToolExecutionMode =
|
||||||
| { kind: 'parallel' }
|
| { kind: 'parallel' }
|
||||||
@@ -245,9 +242,8 @@ export interface ToolRunContext extends ToolExecution {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Internal result of the scheduler-owned `tools/pre-execute` stage. Exported
|
* Scheduler-only result after ordered pre-execute and guards. A `post-result`
|
||||||
* only so `dsh-agent-loop` can split ordered middleware from concurrent
|
* still receives post-execute; a `final-result` bypasses it.
|
||||||
* dispatch without exposing named staged service methods on `ctx.tools`.
|
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
export type ScheduledToolPreparation =
|
export type ScheduledToolPreparation =
|
||||||
@@ -256,10 +252,8 @@ export type ScheduledToolPreparation =
|
|||||||
| { kind: 'final-result'; exec: ToolRunContext; result: ToolExecutionResult }
|
| { kind: 'final-result'; exec: ToolRunContext; result: ToolExecutionResult }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Internal result of the scheduler-owned `tools/execute` stage. A normal tool
|
* Scheduler-only dispatch result. A `post-result` still receives post-execute;
|
||||||
* result still needs ordered post-execute finalization; a pipeline failure
|
* a `final-result` already matches {@link ToolRegistry.execute} failure semantics.
|
||||||
* after/beside dispatch is already final and bypasses post-execute, matching
|
|
||||||
* {@link ToolRegistry.execute}'s public one-call semantics.
|
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
export type ScheduledToolDispatch =
|
export type ScheduledToolDispatch =
|
||||||
@@ -267,10 +261,9 @@ export type ScheduledToolDispatch =
|
|||||||
| { kind: 'final-result'; result: ToolExecutionResult }
|
| { kind: 'final-result'; result: ToolExecutionResult }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Internal scheduler view of the registry pipeline. `dsh-agent-loop` uses this
|
* Symbol-keyed scheduler view that keeps pre/post policy ordered while
|
||||||
* symbol-keyed entry point to keep `tools/pre-execute` and `tools/post-execute`
|
* overlapping dispatch. Ordinary callers use {@link ToolRegistry.execute};
|
||||||
* ordered while overlapping only `tools/execute` dispatch/body. Ordinary
|
* this is not a plugin seam.
|
||||||
* callers use {@link ToolRegistry.execute}; this symbol is not a plugin seam.
|
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
export interface ToolRegistryScheduler {
|
export interface ToolRegistryScheduler {
|
||||||
@@ -285,9 +278,7 @@ export interface ToolRegistryScheduler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Symbol-keyed internal scheduler entry point on {@link ToolRegistry}. The
|
* Scheduler entry point omitted from the generated named service API.
|
||||||
* generated service catalog deliberately skips computed members, so this does
|
|
||||||
* not create a named public staged API.
|
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
export const TOOL_REGISTRY_SCHEDULER: unique symbol = Symbol('@deepseek-ai/dsh-tools.scheduler')
|
export const TOOL_REGISTRY_SCHEDULER: unique symbol = Symbol('@deepseek-ai/dsh-tools.scheduler')
|
||||||
@@ -762,14 +753,11 @@ export class ToolRegistry extends Service {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Classify how one pending call may be scheduled relative to its siblings in
|
* Classify a pending call through the caller's visible tool definition. Only
|
||||||
* the same assistant step. Looks up the tool through the caller's visible
|
* an exact `true` is parallel; unknown, hidden, undeclared, invalid, or
|
||||||
* scoped view and calls its `isConcurrencySafe(exec.arguments)` classifier.
|
* throwing classifiers are exclusive.
|
||||||
* Only an explicit `true` yields `{ kind: 'parallel' }`; unknown,
|
* @param exec - call name, parsed arguments, and optional agent scope.
|
||||||
* restricted-away, undeclared, falsey, or throwing checks fail closed to
|
* @returns the fail-closed scheduling mode.
|
||||||
* `{ kind: 'exclusive' }`.
|
|
||||||
* @param exec - the call to classify (name, parsed arguments, optional agent scope).
|
|
||||||
* @returns the conservative scheduling mode for this call.
|
|
||||||
*/
|
*/
|
||||||
executionMode(exec: ToolExecutionInput): ToolExecutionMode {
|
executionMode(exec: ToolExecutionInput): ToolExecutionMode {
|
||||||
const tool = this.get(exec.name, exec.agent)
|
const tool = this.get(exec.name, exec.agent)
|
||||||
@@ -787,7 +775,6 @@ export class ToolRegistry extends Service {
|
|||||||
* notification. Tool and listener failures resolve as materialized error
|
* notification. Tool and listener failures resolve as materialized error
|
||||||
* results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is
|
* results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is
|
||||||
* the same lossless, frozen snapshot final observers receive.
|
* the same lossless, frozen snapshot final observers receive.
|
||||||
* Scheduler staging preserves these semantics when dispatches overlap.
|
|
||||||
* @param exec - the typed same-process call input. The registry assigns its
|
* @param exec - the typed same-process call input. The registry assigns its
|
||||||
* correlation token before policy begins.
|
* correlation token before policy begins.
|
||||||
* @returns the materialized final result.
|
* @returns the materialized final result.
|
||||||
@@ -796,7 +783,6 @@ export class ToolRegistry extends Service {
|
|||||||
return this.prepareExecution(exec, prepared => this.completeScheduledExecution(prepared))
|
return this.prepareExecution(exec, prepared => this.completeScheduledExecution(prepared))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Complete every remaining stage for the public one-call execution path. */
|
|
||||||
private async completeScheduledExecution(prepared: ScheduledToolPreparation): Promise<ToolExecutionResult> {
|
private async completeScheduledExecution(prepared: ScheduledToolPreparation): Promise<ToolExecutionResult> {
|
||||||
switch (prepared.kind) {
|
switch (prepared.kind) {
|
||||||
case 'dispatch': {
|
case 'dispatch': {
|
||||||
@@ -815,7 +801,6 @@ export class ToolRegistry extends Service {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Materialize caller input into the immutable identity object used by the pipeline. */
|
|
||||||
private createExecution(exec: ToolExecutionInput): ScheduledToolPreparation | { kind: 'ready'; exec: ToolRunContext } {
|
private createExecution(exec: ToolExecutionInput): ScheduledToolPreparation | { kind: 'ready'; exec: ToolRunContext } {
|
||||||
const deferredContexts: HookContext[] = []
|
const deferredContexts: HookContext[] = []
|
||||||
const token = createExecutionToken()
|
const token = createExecutionToken()
|
||||||
@@ -859,7 +844,6 @@ export class ToolRegistry extends Service {
|
|||||||
return this.prepareExecution(input, prepared => prepared)
|
return this.prepareExecution(input, prepared => prepared)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Run preparation and hand its outcome directly to the selected continuation. */
|
|
||||||
private async prepareExecution<T>(
|
private async prepareExecution<T>(
|
||||||
input: ToolExecutionInput,
|
input: ToolExecutionInput,
|
||||||
next: (prepared: ScheduledToolPreparation) => T | PromiseLike<T>,
|
next: (prepared: ScheduledToolPreparation) => T | PromiseLike<T>,
|
||||||
@@ -894,9 +878,8 @@ export class ToolRegistry extends Service {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run only the around-dispatch/body stage. Tool-body and unknown-tool failures
|
* Run around-dispatch and the tool body. Tool and unknown-tool failures still
|
||||||
* are normalized results that still go through post-execute; waterfall or
|
* receive post-execute; pipeline failures are already final.
|
||||||
* registry invariant failures become final results, matching `execute()`.
|
|
||||||
* @param exec - the prepared execution.
|
* @param exec - the prepared execution.
|
||||||
* @returns whether the result still needs post-execute.
|
* @returns whether the result still needs post-execute.
|
||||||
* @internal
|
* @internal
|
||||||
@@ -970,7 +953,7 @@ export class ToolRegistry extends Service {
|
|||||||
return finalResult
|
return finalResult
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Notify final-result observers without giving them a mutation/error channel into the outcome. */
|
/** Notify observers without exposing a mutation or error channel into the outcome. */
|
||||||
private notifyResult(exec: ToolExecution, result: ToolExecutionResult): void {
|
private notifyResult(exec: ToolExecution, result: ToolExecutionResult): void {
|
||||||
// Freeze the remaining mutable signal slot before observers receive the
|
// Freeze the remaining mutable signal slot before observers receive the
|
||||||
// shared WeakMap-keyable execution object.
|
// shared WeakMap-keyable execution object.
|
||||||
|
|||||||
@@ -284,13 +284,11 @@ export interface DefineToolOptions<S extends SchemaSpec> {
|
|||||||
*/
|
*/
|
||||||
readonly timeoutMs?: number
|
readonly timeoutMs?: number
|
||||||
/**
|
/**
|
||||||
* Optional synchronous concurrency-safety classifier (see
|
* Optional pure synchronous classifier for sibling overlap. It receives typed
|
||||||
* {@link ToolDefinition.isConcurrencySafe}). `args` is the typed, schema-
|
* arguments after soft validation; invalid input returns `false` without
|
||||||
* validated shape — zero casts. Validated SOFTLY, mirroring the presenters:
|
* invoking it. See {@link ToolDefinition.isConcurrencySafe}.
|
||||||
* on an arg mismatch the produced classifier returns `false` (the conservative
|
* @param args - typed validated arguments.
|
||||||
* exclusive default) instead of the hard {@link ToolArgsError} the execute path
|
* @returns whether this call may join a parallel group.
|
||||||
* raises, since replay/scheduling may feed older-schema args. Host-only — never
|
|
||||||
* sent to the model.
|
|
||||||
*/
|
*/
|
||||||
isConcurrencySafe?(args: InferArgs<S>): boolean
|
isConcurrencySafe?(args: InferArgs<S>): boolean
|
||||||
/**
|
/**
|
||||||
@@ -325,7 +323,7 @@ export interface DefineToolOptions<S extends SchemaSpec> {
|
|||||||
* @param options - the tool's name, description, typed parameter schema,
|
* @param options - the tool's name, description, typed parameter schema,
|
||||||
* execute body, and optional presenters.
|
* execute body, and optional presenters.
|
||||||
* @returns a registry-ready definition with strict execution validation and
|
* @returns a registry-ready definition with strict execution validation and
|
||||||
* soft presenter and concurrency-classifier validation for replay compatibility.
|
* soft presenter and classifier validation for replay compatibility.
|
||||||
*/
|
*/
|
||||||
export function defineTool<S extends SchemaSpec>(options: DefineToolOptions<S>): ToolDefinition {
|
export function defineTool<S extends SchemaSpec>(options: DefineToolOptions<S>): ToolDefinition {
|
||||||
// Object-literal execute methods don't use `this`; the reference is safe.
|
// Object-literal execute methods don't use `this`; the reference is safe.
|
||||||
@@ -371,10 +369,7 @@ export function defineTool<S extends SchemaSpec>(options: DefineToolOptions<S>):
|
|||||||
return userPresentResult(args as InferArgs<S>, result)
|
return userPresentResult(args as InferArgs<S>, result)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Concurrency classification is host-only scheduler metadata (never sent to
|
// Invalid arguments fail closed without invoking the typed classifier.
|
||||||
// the model) and, like the presenters, may run against replay/scheduling args
|
|
||||||
// from an older schema — so it validates SOFTLY: an arg mismatch returns
|
|
||||||
// `false` (conservative exclusive default), never the hard ToolArgsError.
|
|
||||||
if (userIsConcurrencySafe) {
|
if (userIsConcurrencySafe) {
|
||||||
tool.isConcurrencySafe = (args: unknown): boolean => {
|
tool.isConcurrencySafe = (args: unknown): boolean => {
|
||||||
if (validateArgs(options.parameters, args).length > 0) return false
|
if (validateArgs(options.parameters, args).length > 0) return false
|
||||||
|
|||||||
@@ -1,9 +1,4 @@
|
|||||||
/**
|
/** Covers fail-closed per-call classification and model-schema isolation. */
|
||||||
* Per-call concurrency classification: `ToolDefinition.isConcurrencySafe`,
|
|
||||||
* `defineTool()`'s soft-validated forwarding of it, and the registry's
|
|
||||||
* `executionMode(exec)` decision. Also proves the classifier never leaks into
|
|
||||||
* the model-facing `schemas()` projection.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, expect, expectTypeOf, it } from 'vitest'
|
import { describe, expect, expectTypeOf, it } from 'vitest'
|
||||||
import { Context } from 'cordis'
|
import { Context } from 'cordis'
|
||||||
@@ -28,7 +23,7 @@ function exec(name: string, args: unknown): ToolExecutionInput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('ToolRegistry.executionMode', () => {
|
describe('ToolRegistry.executionMode', () => {
|
||||||
it('returns parallel only when the registered tool declares isConcurrencySafe → true', async () => {
|
it('returns parallel only for an explicit true classifier', async () => {
|
||||||
const ctx = await setup()
|
const ctx = await setup()
|
||||||
ctx.tools.register(defineTool({
|
ctx.tools.register(defineTool({
|
||||||
name: 'safe',
|
name: 'safe',
|
||||||
@@ -58,7 +53,6 @@ describe('ToolRegistry.executionMode', () => {
|
|||||||
|
|
||||||
it('returns exclusive when the classifier returns false for these args', async () => {
|
it('returns exclusive when the classifier returns false for these args', async () => {
|
||||||
const ctx = await setup()
|
const ctx = await setup()
|
||||||
// Input-sensitive: safe to read, unsafe to write — the same tool differs by args.
|
|
||||||
ctx.tools.register(defineTool({
|
ctx.tools.register(defineTool({
|
||||||
name: 'rw',
|
name: 'rw',
|
||||||
description: 'read or write',
|
description: 'read or write',
|
||||||
@@ -70,11 +64,8 @@ describe('ToolRegistry.executionMode', () => {
|
|||||||
expect(ctx.tools.executionMode(exec('rw', { mode: 'write' }))).toEqual({ kind: 'exclusive' })
|
expect(ctx.tools.executionMode(exec('rw', { mode: 'write' }))).toEqual({ kind: 'exclusive' })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('a defineTool classifier soft-fails to exclusive on invalid args (no ToolArgsError)', async () => {
|
it('classifies invalid defineTool arguments as exclusive without throwing', async () => {
|
||||||
const ctx = await setup()
|
const ctx = await setup()
|
||||||
// The typed classifier would read args.mode, but the required arg is missing:
|
|
||||||
// soft validation returns false (exclusive) rather than throwing, matching the
|
|
||||||
// presenter pattern. Executing the same bad args WOULD raise ToolArgsError.
|
|
||||||
ctx.tools.register(defineTool({
|
ctx.tools.register(defineTool({
|
||||||
name: 'needs-mode',
|
name: 'needs-mode',
|
||||||
description: 'requires mode',
|
description: 'requires mode',
|
||||||
@@ -85,9 +76,8 @@ describe('ToolRegistry.executionMode', () => {
|
|||||||
expect(ctx.tools.executionMode(exec('needs-mode', {}))).toEqual({ kind: 'exclusive' })
|
expect(ctx.tools.executionMode(exec('needs-mode', {}))).toEqual({ kind: 'exclusive' })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('a thrown classifier fails closed to exclusive (raw definition)', async () => {
|
it('treats a throwing raw classifier as exclusive', async () => {
|
||||||
const ctx = await setup()
|
const ctx = await setup()
|
||||||
// A hand-rolled ToolDefinition (not via defineTool) whose check throws.
|
|
||||||
const raw: ToolDefinition = {
|
const raw: ToolDefinition = {
|
||||||
name: 'thrower',
|
name: 'thrower',
|
||||||
description: 'classifier throws',
|
description: 'classifier throws',
|
||||||
@@ -99,7 +89,7 @@ describe('ToolRegistry.executionMode', () => {
|
|||||||
expect(ctx.tools.executionMode(exec('thrower', {}))).toEqual({ kind: 'exclusive' })
|
expect(ctx.tools.executionMode(exec('thrower', {}))).toEqual({ kind: 'exclusive' })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('a truthy non-boolean classifier result fails closed to exclusive (raw definition)', async () => {
|
it('treats a truthy non-boolean raw result as exclusive', async () => {
|
||||||
const ctx = await setup()
|
const ctx = await setup()
|
||||||
const raw = {
|
const raw = {
|
||||||
name: 'truthy',
|
name: 'truthy',
|
||||||
@@ -112,7 +102,7 @@ describe('ToolRegistry.executionMode', () => {
|
|||||||
expect(ctx.tools.executionMode(exec('truthy', {}))).toEqual({ kind: 'exclusive' })
|
expect(ctx.tools.executionMode(exec('truthy', {}))).toEqual({ kind: 'exclusive' })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('a raw definition (no defineTool) receives the raw parsed value', async () => {
|
it('passes parsed arguments directly to a raw definition', async () => {
|
||||||
const ctx = await setup()
|
const ctx = await setup()
|
||||||
let seen: unknown
|
let seen: unknown
|
||||||
ctx.tools.register({
|
ctx.tools.register({
|
||||||
|
|||||||
@@ -27,18 +27,14 @@ export const name = 'acp-demo'
|
|||||||
* deployment persona (forwarded to the system-prompt plugin); `toolOrder` is
|
* deployment persona (forwarded to the system-prompt plugin); `toolOrder` is
|
||||||
* the explicit model-facing tool order (forwarded to the system-prompt plugin);
|
* the explicit model-facing tool order (forwarded to the system-prompt plugin);
|
||||||
* `tools` is the tool registry's config (its presentation `mode`, forwarded
|
* `tools` is the tool registry's config (its presentation `mode`, forwarded
|
||||||
* through agent-spine-demo); `maxParallelToolCalls` configures the bundled
|
* through agent-spine-demo); `persistenceRoot` is the JSONL backend's directory.
|
||||||
* agent loop; `persistenceRoot` is the JSONL backend's directory.
|
|
||||||
*/
|
*/
|
||||||
export interface Config {
|
export interface Config {
|
||||||
/** Provider route for ACP-created agents. */
|
/** Provider route for ACP-created agents. */
|
||||||
provider: string
|
provider: string
|
||||||
/** Model name for ACP-created agents (must have a registered adapter). */
|
/** Model name for ACP-created agents (must have a registered adapter). */
|
||||||
model: string
|
model: string
|
||||||
/**
|
/** Bundled agent-loop concurrency cap; `1` is serial and omission uses its default. */
|
||||||
* Concurrent parallel-safe tool-call cap for the bundled agent loop. A
|
|
||||||
* positive integer; the loop defaults it when omitted and `1` is serial.
|
|
||||||
*/
|
|
||||||
maxParallelToolCalls?: number
|
maxParallelToolCalls?: number
|
||||||
/** Deployment persona (the system-prompt plugin's `persona` config). */
|
/** Deployment persona (the system-prompt plugin's `persona` config). */
|
||||||
persona?: string
|
persona?: string
|
||||||
@@ -66,8 +62,6 @@ export interface Config {
|
|||||||
export const Config: z<Config> = z.object({
|
export const Config: z<Config> = z.object({
|
||||||
provider: z.string().required(),
|
provider: z.string().required(),
|
||||||
model: z.string().required(),
|
model: z.string().required(),
|
||||||
// A positive integer; a bad value (0, negative, fractional) fails config
|
|
||||||
// validation here rather than being silently dropped from cordis.yml.
|
|
||||||
maxParallelToolCalls: z.number().step(1).min(1),
|
maxParallelToolCalls: z.number().step(1).min(1),
|
||||||
persona: z.string(),
|
persona: z.string(),
|
||||||
// The array default is forced to undefined: ABSENT means "lexicographic
|
// The array default is forced to undefined: ABSENT means "lexicographic
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export interface SkillConfig {
|
|||||||
export interface Config {
|
export interface Config {
|
||||||
/** The agent-loop `agents` list (see dsh-agent-loop's `Config`). */
|
/** The agent-loop `agents` list (see dsh-agent-loop's `Config`). */
|
||||||
agents?: AgentLoopConfig['agents']
|
agents?: AgentLoopConfig['agents']
|
||||||
/** Shared concurrent tool-call cap (see dsh-agent-loop's `Config`). */
|
/** Agent-loop concurrency cap; `1` is serial. */
|
||||||
maxParallelToolCalls?: AgentLoopConfig['maxParallelToolCalls']
|
maxParallelToolCalls?: AgentLoopConfig['maxParallelToolCalls']
|
||||||
/** The deployment persona (see dsh-system-prompt's `Config`). */
|
/** The deployment persona (see dsh-system-prompt's `Config`). */
|
||||||
persona?: SystemPromptConfig['persona']
|
persona?: SystemPromptConfig['persona']
|
||||||
|
|||||||
@@ -39,10 +39,7 @@ export interface Config {
|
|||||||
provider: string
|
provider: string
|
||||||
/** Model name for the `main` agent (must have a registered adapter). */
|
/** Model name for the `main` agent (must have a registered adapter). */
|
||||||
model: string
|
model: string
|
||||||
/**
|
/** Bundled agent-loop concurrency cap; `1` is serial and omission uses its default. */
|
||||||
* Concurrent parallel-safe tool-call cap for the bundled agent loop. A
|
|
||||||
* positive integer; the loop defaults it when omitted and `1` is serial.
|
|
||||||
*/
|
|
||||||
maxParallelToolCalls?: number
|
maxParallelToolCalls?: number
|
||||||
/** Deployment persona (the system-prompt plugin's `persona` config). */
|
/** Deployment persona (the system-prompt plugin's `persona` config). */
|
||||||
persona?: string
|
persona?: string
|
||||||
@@ -75,8 +72,6 @@ export interface Config {
|
|||||||
export const Config: z<Config> = z.object({
|
export const Config: z<Config> = z.object({
|
||||||
provider: z.string().required(),
|
provider: z.string().required(),
|
||||||
model: z.string().required(),
|
model: z.string().required(),
|
||||||
// A positive integer; a bad value (0, negative, fractional) fails config
|
|
||||||
// validation here rather than being silently dropped from cordis.yml.
|
|
||||||
maxParallelToolCalls: z.number().step(1).min(1),
|
maxParallelToolCalls: z.number().step(1).min(1),
|
||||||
persona: z.string(),
|
persona: z.string(),
|
||||||
// The array default is forced to undefined: ABSENT means "lexicographic
|
// The array default is forced to undefined: ABSENT means "lexicographic
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ The tool passes `exec` (the tool-execution context) as the opaque `actor` on eve
|
|||||||
|
|
||||||
`fs/observed` fires AFTER the read/write/edit already succeeded, via a plain `ctx.emit`. A listener is contractually a synchronous, side-effect-only recorder (`@deepseek-ai/dsh-fs-policy`'s is a `WeakMap.set`); the tool does not guard the emit, so a listener that throws would surface as the tool's `isError` result — async or fallible observation does not belong on this event.
|
`fs/observed` fires AFTER the read/write/edit already succeeded, via a plain `ctx.emit`. A listener is contractually a synchronous, side-effect-only recorder (`@deepseek-ai/dsh-fs-policy`'s is a `WeakMap.set`); the tool does not guard the emit, so a listener that throws would surface as the tool's `isError` result — async or fallible observation does not belong on this event.
|
||||||
|
|
||||||
This is why `read` opts into concurrent scheduling while `write` and `edit` remain exclusive. Concurrent reads may race only in the synchronous version recorder; a later write or edit re-checks that version under its per-target lock, so stale state produces `FS_STALE_VERSION` rather than an unsafe mutation. See the [parallel tool-call RFC](../../../docs/rfc/implemented/feature/2026-07-10-parallel-tool-call-execution.md).
|
`read` opts into concurrent scheduling because its only mutation is the synchronous version recorder. Recorder races fail closed when a later `write` or `edit` re-checks the version under its target lock; both mutation tools remain exclusive. See the [parallel tool-call RFC](../../../docs/rfc/implemented/feature/2026-07-10-parallel-tool-call-execution.md).
|
||||||
|
|
||||||
The package root exports only the Cordis plugin contract (`name`, `inject`, `Config`, and `apply`). Read rendering (line windowing + output formatting) lives in `src/read-render.ts` (Cordis-free, independently unit-tested); `src/read.ts`/`write.ts`/`edit.ts` are the tool executors and `src/index.ts` composes them.
|
The package root exports only the Cordis plugin contract (`name`, `inject`, `Config`, and `apply`). Read rendering (line windowing + output formatting) lives in `src/read-render.ts` (Cordis-free, independently unit-tested); `src/read.ts`/`write.ts`/`edit.ts` are the tool executors and `src/index.ts` composes them.
|
||||||
|
|
||||||
|
|||||||
@@ -84,12 +84,7 @@ export function applyReadTool(ctx: Context, caps: ReadToolCaps): void {
|
|||||||
offset: { type: 'number', description: '1-based first line to return. Defaults to 1.' },
|
offset: { type: 'number', description: '1-based first line to return. Defaults to 1.' },
|
||||||
limit: { type: 'number', description: `Maximum number of lines to return. Defaults to ${caps.limit}.` },
|
limit: { type: 'number', description: `Maximum number of lines to return. Defaults to ${caps.limit}.` },
|
||||||
},
|
},
|
||||||
// Read-only. Its one side effect is the synchronous `fs/observed` version
|
// Observation races fail closed because guarded mutations re-check the version in-lock.
|
||||||
// recorder (a WeakMap write; see below and the fs-policy plugin): concurrent
|
|
||||||
// same-target reads race last-writer-wins on that record, which is safe because
|
|
||||||
// it is NOT the safety boundary — write/edit stay exclusive barriers and
|
|
||||||
// re-check the version in-lock, so a stale observation only makes a later edit
|
|
||||||
// fail closed with FS_STALE_VERSION.
|
|
||||||
isConcurrencySafe: () => true,
|
isConcurrencySafe: () => true,
|
||||||
async execute(args, exec): Promise<ContentBlock[]> {
|
async execute(args, exec): Promise<ContentBlock[]> {
|
||||||
const input = parseReadArgs(args, caps.limit)
|
const input = parseReadArgs(args, caps.limit)
|
||||||
|
|||||||
@@ -398,9 +398,7 @@ describe('signal, concurrency, and the fs/observed contract', () => {
|
|||||||
expect(secondInfo.version).not.toBe(firstInfo.version)
|
expect(secondInfo.version).not.toBe(firstInfo.version)
|
||||||
expect((await callOwned('read', { file_path: 'a.txt' })).isError).toBe(false)
|
expect((await callOwned('read', { file_path: 'a.txt' })).isError).toBe(false)
|
||||||
|
|
||||||
// Simulate an older concurrent read finishing last and overwriting the
|
// Reproduce an older concurrent read winning the observation race.
|
||||||
// observed-state WeakMap with the stale version it saw before the external
|
|
||||||
// file change. The provider's in-lock CAS is still the safety boundary.
|
|
||||||
ctx.emit('fs/observed', target, firstInfo.version, { agent: { session } })
|
ctx.emit('fs/observed', target, firstInfo.version, { agent: { session } })
|
||||||
|
|
||||||
const edit = await callOwned('edit', {
|
const edit = await callOwned('edit', {
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ With `run_in_background: true`, the tool registers the parent-owned task before
|
|||||||
|
|
||||||
## Concurrency
|
## Concurrency
|
||||||
|
|
||||||
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).
|
Foreground and background calls are exclusive. Children may share the parent's workspace or external resources, and a unary 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
|
## Model Experience
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ Each tool is registered independently; a product that wants only one disables th
|
|||||||
| `web_search` | `query` (string) | Discovery. Returns an optional answer plus source URLs. `max_results` is **not** model-facing — the tool sets the bound (the `searchMaxResults` config, default 8) and passes it to the seam. |
|
| `web_search` | `query` (string) | Discovery. Returns an optional answer plus source URLs. `max_results` is **not** model-facing — the tool sets the bound (the `searchMaxResults` config, default 8) and passes it to the seam. |
|
||||||
| `web_fetch` | `url` (string) | Retrieves a specific URL. HTML bodies are rendered to markdown-ish text; text bodies pass through. A non-2xx status is reported, not an error. The tool-call timeout is deployment policy (`dsh-timeout-policy`), not a model argument. |
|
| `web_fetch` | `url` (string) | Retrieves a specific URL. HTML bodies are rendered to markdown-ish text; text bodies pass through. A non-2xx status is reported, not an error. The tool-call timeout is deployment policy (`dsh-timeout-policy`), not a model argument. |
|
||||||
|
|
||||||
Both tools declare `isConcurrencySafe: () => true` — they are read-only (fetch a provider/URL, return content, mutate no parent-agent state), so the agent loop may run sibling web calls in parallel.
|
Both tools opt into concurrent scheduling because provider reads return content without mutating parent-agent state.
|
||||||
|
|
||||||
## Config
|
## Config
|
||||||
|
|
||||||
|
|||||||
@@ -92,8 +92,7 @@ export function applyWebFetchTool(ctx: Context, timeoutMs: number): void {
|
|||||||
url: { type: 'string', required: true, description: 'The HTTP(S) URL to fetch.' },
|
url: { type: 'string', required: true, description: 'The HTTP(S) URL to fetch.' },
|
||||||
},
|
},
|
||||||
timeoutMs,
|
timeoutMs,
|
||||||
// Read-only: fetching a URL returns content and mutates no parent-agent
|
// Provider reads do not mutate parent-agent state.
|
||||||
// state — safe to run concurrently with sibling calls.
|
|
||||||
isConcurrencySafe: () => true,
|
isConcurrencySafe: () => true,
|
||||||
async execute(args, exec): Promise<ContentBlock[]> {
|
async execute(args, exec): Promise<ContentBlock[]> {
|
||||||
const input = parseFetchArgs(args)
|
const input = parseFetchArgs(args)
|
||||||
|
|||||||
@@ -109,8 +109,7 @@ export function applyWebSearchTool(ctx: Context, maxResults: number, timeoutMs:
|
|||||||
query: { type: 'string', required: true, description: 'The search query.' },
|
query: { type: 'string', required: true, description: 'The search query.' },
|
||||||
},
|
},
|
||||||
timeoutMs,
|
timeoutMs,
|
||||||
// Read-only: a search hits the provider and returns content, mutating no
|
// Provider reads do not mutate parent-agent state.
|
||||||
// parent-agent state — safe to run concurrently with sibling calls.
|
|
||||||
isConcurrencySafe: () => true,
|
isConcurrencySafe: () => true,
|
||||||
async execute(args, exec): Promise<ContentBlock[]> {
|
async execute(args, exec): Promise<ContentBlock[]> {
|
||||||
const input = parseSearchArgs(args)
|
const input = parseSearchArgs(args)
|
||||||
|
|||||||
@@ -839,14 +839,14 @@ function renderLifecycle(): string {
|
|||||||
` Session-->>SDK: ${mermaidCode('session/event')} ${mermaidCode('assistant/chunk')}*`,
|
` Session-->>SDK: ${mermaidCode('session/event')} ${mermaidCode('assistant/chunk')}*`,
|
||||||
` Driver->>Hooks: ${mermaidCode('agent/step-result')} waterfall`,
|
` Driver->>Hooks: ${mermaidCode('agent/step-result')} waterfall`,
|
||||||
` Driver->>Session: ${mermaidCode('assistant/message')}`,
|
` Driver->>Session: ${mermaidCode('assistant/message')}`,
|
||||||
' Driver->>Tools: classify next call by executionMode',
|
' Driver->>Tools: classify pending call by executionMode',
|
||||||
' loop bounded rolling pool with reclassification before replenishing',
|
' loop barriers and bounded rolling pool, reclassify before start',
|
||||||
' opt capacity available for an unstarted call',
|
' opt call starts',
|
||||||
` Driver->>Session: ${mermaidCode('tool/call')} pending audit`,
|
` Driver->>Session: ${mermaidCode('tool/call')}`,
|
||||||
' Driver->>Tools: ordered pre / pooled dispatch',
|
' Driver->>Tools: ordered pre, concurrent execute',
|
||||||
' Tools-->>Session: tool-owned events when applicable',
|
' Tools-->>Session: tool-owned events when applicable',
|
||||||
' end',
|
' end',
|
||||||
' opt next model-order result is ready',
|
' opt next model-order result ready',
|
||||||
' Driver->>Tools: ordered post',
|
' Driver->>Tools: ordered post',
|
||||||
` Driver->>Session: ${mermaidCode('tool/result')}`,
|
` Driver->>Session: ${mermaidCode('tool/result')}`,
|
||||||
' end',
|
' end',
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
Concrete ReactLoopAgent factory and driver service.
|
Concrete ReactLoopAgent factory and driver service.
|
||||||
|
|
||||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent-loop/src/index.ts#L353)
|
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent-loop/src/index.ts#L352)
|
||||||
|
|
||||||
### ctx.agentLoop.create(id, options?, meta?)
|
### ctx.agentLoop.create(id, options?, meta?)
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ Create an agent on a fresh per-run session, owned by the accessing fiber. Constr
|
|||||||
|
|
||||||
**Returns** the published running agent.
|
**Returns** the published running agent.
|
||||||
|
|
||||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent-loop/src/index.ts#L414)
|
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent-loop/src/index.ts#L412)
|
||||||
|
|
||||||
### ctx.agentLoop.createAgent(ownerCtx, options)
|
### ctx.agentLoop.createAgent(ownerCtx, options)
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ Create an owned agent on a caller-supplied session id.
|
|||||||
|
|
||||||
**Returns** the published handle.
|
**Returns** the published handle.
|
||||||
|
|
||||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent-loop/src/index.ts#L437)
|
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent-loop/src/index.ts#L435)
|
||||||
|
|
||||||
### ctx.agentLoop.resume(ownerCtx, options)
|
### ctx.agentLoop.resume(ownerCtx, options)
|
||||||
|
|
||||||
@@ -52,4 +52,4 @@ Resume an owned agent from the configured persistence service.
|
|||||||
|
|
||||||
**Returns** the published handle.
|
**Returns** the published handle.
|
||||||
|
|
||||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent-loop/src/index.ts#L469)
|
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent-loop/src/index.ts#L467)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
Tool registry and execution pipeline. Scoped registrations shadow globals; one visibility resolver feeds presentation, lookup, and dispatch.
|
Tool registry and execution pipeline. Scoped registrations shadow globals; one visibility resolver feeds presentation, lookup, and dispatch.
|
||||||
|
|
||||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L447)
|
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L438)
|
||||||
|
|
||||||
### ctx.tools.register(definition)
|
### ctx.tools.register(definition)
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ Register globally or in the calling agent scope. Scoped tools shadow globals; du
|
|||||||
|
|
||||||
**Returns** the exact disposer that unregisters the tool.
|
**Returns** the exact disposer that unregisters the tool.
|
||||||
|
|
||||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L547)
|
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L538)
|
||||||
|
|
||||||
### ctx.tools.restrict(filter)
|
### ctx.tools.restrict(filter)
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@ Restrict global tools for the calling agent scope. Empty filters, unknown names,
|
|||||||
|
|
||||||
**Returns** the exact disposer that lifts this restriction.
|
**Returns** the exact disposer that lifts this restriction.
|
||||||
|
|
||||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L587)
|
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L578)
|
||||||
|
|
||||||
### ctx.tools.guard(guard)
|
### ctx.tools.guard(guard)
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ Register a monotonic guard after the extensible `tools/pre-execute` waterfall. A
|
|||||||
|
|
||||||
**Returns** the exact disposer that unregisters the guard.
|
**Returns** the exact disposer that unregisters the guard.
|
||||||
|
|
||||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L638)
|
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L629)
|
||||||
|
|
||||||
### ctx.tools.get(name, scope?)
|
### ctx.tools.get(name, scope?)
|
||||||
|
|
||||||
@@ -63,7 +63,7 @@ Look up a tool as one scope sees it (scoped shadows global; a restricted-away gl
|
|||||||
|
|
||||||
**Returns** the definition the scope resolves, or undefined when none is visible.
|
**Returns** the definition the scope resolves, or undefined when none is visible.
|
||||||
|
|
||||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L740)
|
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L731)
|
||||||
|
|
||||||
### ctx.tools.schemas(scope?)
|
### ctx.tools.schemas(scope?)
|
||||||
|
|
||||||
@@ -77,7 +77,7 @@ Project visible definitions onto the allowlisted model-facing schema fields, exc
|
|||||||
|
|
||||||
**Returns** one deep-cloned schema per visible tool.
|
**Returns** one deep-cloned schema per visible tool.
|
||||||
|
|
||||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L750)
|
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L741)
|
||||||
|
|
||||||
### ctx.tools.executionMode(exec)
|
### ctx.tools.executionMode(exec)
|
||||||
|
|
||||||
@@ -85,13 +85,13 @@ Project visible definitions onto the allowlisted model-facing schema fields, exc
|
|||||||
executionMode(exec: ToolExecutionInput): ToolExecutionMode
|
executionMode(exec: ToolExecutionInput): ToolExecutionMode
|
||||||
```
|
```
|
||||||
|
|
||||||
Classify how one pending call may be scheduled relative to its siblings in the same assistant step. Looks up the tool through the caller's visible scoped view and calls its `isConcurrencySafe(exec.arguments)` classifier. Only an explicit `true` yields `{ kind: 'parallel' }`; unknown, restricted-away, undeclared, falsey, or throwing checks fail closed to `{ kind: 'exclusive' }`.
|
Classify a pending call through the caller's visible tool definition. Only an exact `true` is parallel; unknown, hidden, undeclared, invalid, or throwing classifiers are exclusive.
|
||||||
|
|
||||||
- `exec` — the call to classify (name, parsed arguments, optional agent scope).
|
- `exec` — call name, parsed arguments, and optional agent scope.
|
||||||
|
|
||||||
**Returns** the conservative scheduling mode for this call.
|
**Returns** the fail-closed scheduling mode.
|
||||||
|
|
||||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L774)
|
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L762)
|
||||||
|
|
||||||
### ctx.tools.execute(exec)
|
### ctx.tools.execute(exec)
|
||||||
|
|
||||||
@@ -99,10 +99,10 @@ Classify how one pending call may be scheduled relative to its siblings in the s
|
|||||||
async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>
|
async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>
|
||||||
```
|
```
|
||||||
|
|
||||||
Execute through pre-policy, guards, around-dispatch, post-policy, and final notification. Tool and listener failures resolve as materialized error results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen snapshot final observers receive. Scheduler staging preserves these semantics when dispatches overlap.
|
Execute through pre-policy, guards, around-dispatch, post-policy, and final notification. Tool and listener failures resolve as materialized error results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen snapshot final observers receive.
|
||||||
|
|
||||||
- `exec` — the typed same-process call input. The registry assigns its correlation token before policy begins.
|
- `exec` — the typed same-process call input. The registry assigns its correlation token before policy begins.
|
||||||
|
|
||||||
**Returns** the materialized final result.
|
**Returns** the materialized final result.
|
||||||
|
|
||||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L795)
|
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L782)
|
||||||
|
|||||||
Reference in New Issue
Block a user