docs(tools): document parallel read-only tool contracts

Record the MCP resource provider concurrency requirement, the
working-directory recovery notice, and why lsp, goal, job_output and
job_kill stay exclusive; fix unbound-method lint in declaration tests.

Refs #8084

Co-authored-by: liukx0205 <[email protected]>
Co-authored-by: LegGasai <[email protected]>
This commit is contained in:
committed 2026-11-06 21:22:09 +08:00
1 parent 270e7c5a56
commit edd2eee9e9
20 files changed
+58 -44

No files matched your search

@@ -18,11 +18,11 @@
en: 16c4ee08b65ead23
zh: df80d6e49982615b
/agent-note-parallel-tool-call-execution-by-per-call-safety/configuration-and-declarations:
en: 11cbaa5f4037c058
zh: 1918e44d73ffe75d
en: 082e5a02b092b656
zh: ca674a08323d8cc1
/agent-note-parallel-tool-call-execution-by-per-call-safety/verification:
en: 224b6838130c113f
zh: f3d7eb68ab5932fb
en: 20d0e1724ebc1247
zh: baa89694cea135c8
/agent-note-parallel-tool-call-execution-by-per-call-safety/alternatives-considered:
en: 17becc038bb7df7c
zh: 673d9a9bdfa39edf
@@ -60,13 +60,13 @@ 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](../../../../docs/config-catalog.md).
The shipped declarations are conservative. Web search, web fetch, filesystem read, glob, grep, the session-query trace/read tools, and subagent delegation opt in — delegation because a child works in its own session and its run never mutates the parent session, with sibling workspace coordination owned by the model ([parallel subagent Agent Note](../../archived/feature/2026-08-09-parallel-subagent-delegations.md)). Read-only queries also opt in: the background job list, the subagent tree and model listings, scheduled task and Team task reads, Cordis Inspect queries, and MCP resource reads. Filesystem writes and edits, bash tools, `job_output` because it consumes a read cursor, the session-query search tools, workflow, user interaction, todo mutation, PTC mode, and Cordis mutation tools remain exclusive. Bash has no proven input-sensitive classifier and remains exclusive.
The shipped declarations are conservative. Web search, web fetch, filesystem read, glob, grep, the session-query trace/read tools, and subagent delegation opt in — delegation because a child works in its own session and its run never mutates the parent session, with sibling workspace coordination owned by the model ([parallel subagent Agent Note](../../archived/feature/2026-08-09-parallel-subagent-delegations.md)). Read-only queries also opt in: the background job list, the subagent tree and model listings, scheduled task and Team task reads, Cordis Inspect queries, and MCP resource reads. Filesystem writes and edits, bash tools, `job_output` because it consumes a read cursor, the session-query search tools, workflow, user interaction, todo mutation, PTC mode, and Cordis mutation tools remain exclusive. Bash has no proven input-sensitive classifier and remains exclusive. Read-only `lsp` stays exclusive because its language-server pool already serializes queries per workspace, so overlap would gain nothing, and the goal tools stay exclusive under the [goal tools Agent Note](2026-07-19-model-facing-goal-tools.md).
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, glob, and grep also share a working-directory recovery exception: when the Session's recorded directory is unset or missing, the body's `ctx.workingDirectory.ensure()` appends `working-directory/change`, and the working-directory service serializes those appends per Session.
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, glob, and grep also share a working-directory recovery exception: when the Session's recorded directory is unset or missing, the body's `ctx.workingDirectory.ensure()` appends `working-directory/change` and, when it restores a missing directory, injects a model-visible notice; the working-directory service serializes that recovery per Session.
## Verification
Unit coverage pins fail-closed classification, typed argument validation, grouping, barriers, rejection of unstarted calls after registry replacement, the rolling cap, distinct execution objects, middleware order, ordered results and context, abort draining, and scheduler-failure quiescence. First-party tests pin each parallel declaration.
Unit coverage pins fail-closed classification, typed argument validation, grouping, barriers, rejection of unstarted calls after registry replacement, the rolling cap, distinct execution objects, middleware order, ordered results and context, abort draining, and scheduler-failure quiescence. First-party tests pin each parallel declaration, and the working-directory service tests pin per-Session serialization of concurrent directory changes.
Snapshot coverage pins the visible multi-call transcript: pending calls may overlap while completed results remain model-ordered. PTC mode coverage pins its serial boundary. No provider-backed e2e is required because scheduling is deterministic loop behavior.
@@ -60,13 +60,13 @@ Status: implemented
`maxParallelToolCalls` 是 AgentLoop 的正整数部署上限,由工厂创建的所有 agent(智能体)共享。默认值为 `10`;`1` 保持串行执行。字段和默认值的精确定义见生成的[配置目录](../../../../docs/config-catalog.zh.md)。
当前实现中的声明保持保守。Web 搜索、Web 获取、文件系统读取、glob、grep、会话查询的 trace/read 工具和 subagent 委派选择并行;委派之所以并行,是因为子 agent 在自己的会话中工作,其运行绝不变更父会话,并列委派间的工作区协调由模型负责([并行 subagent Agent Note](../../archived/feature/2026-08-09-parallel-subagent-delegations.md))。只读查询也选择并行:后台任务列表、subagent 树与模型列表、定时任务与 Team 任务读取、Cordis Inspect 查询以及 MCP 资源读取。文件系统写入与编辑、bash 工具、`job_output`(因为它会消费读取游标)、会话查询的 search 工具、工作流、用户交互、todo 变更、PTC mode 以及 Cordis 变更工具仍按独占方式执行。Bash 没有已证明的输入敏感分类器,因此仍按独占方式执行。
当前实现中的声明保持保守。Web 搜索、Web 获取、文件系统读取、glob、grep、会话查询的 trace/read 工具和 subagent 委派选择并行;委派之所以并行,是因为子 agent 在自己的会话中工作,其运行绝不变更父会话,并列委派间的工作区协调由模型负责([并行 subagent Agent Note](../../archived/feature/2026-08-09-parallel-subagent-delegations.md))。只读查询也选择并行:后台任务列表、subagent 树与模型列表、定时任务与 Team 任务读取、Cordis Inspect 查询以及 MCP 资源读取。文件系统写入与编辑、bash 工具、`job_output`(因为它会消费读取游标)、会话查询的 search 工具、工作流、用户交互、todo 变更、PTC mode 以及 Cordis 变更工具仍按独占方式执行。Bash 没有已证明的输入敏感分类器,因此仍按独占方式执行。只读的 `lsp` 仍按独占方式执行,因为其语言服务器池已按工作区串行化查询,重叠执行不会带来收益;goal 工具依照 [goal 工具 Agent Note](2026-07-19-model-facing-goal-tools.zh.md) 仍按独占方式执行。
文件系统读取依赖一个范围很窄的记录器例外:其同步观察更新可以不按顺序结算,但写入和编辑在变更前会重新检查已观察的版本,因此陈旧状态只会导致 `FS_STALE_VERSION`。文件系统读取、glob 和 grep 还共享一个工作目录恢复例外:当会话记录的目录未设置或不存在时,工具主体中的 `ctx.workingDirectory.ensure()` 会追加 `working-directory/change`,而工作目录服务按会话串行化这些追加。
文件系统读取依赖一个范围很窄的记录器例外:其同步观察更新可以不按顺序结算,但写入和编辑在变更前会重新检查已观察的版本,因此陈旧状态只会导致 `FS_STALE_VERSION`。文件系统读取、glob 和 grep 还共享一个工作目录恢复例外:当会话记录的目录未设置或不存在时,工具主体中的 `ctx.workingDirectory.ensure()` 会追加 `working-directory/change`,并在恢复不存在的目录时注入一条模型可见的通知;工作目录服务按会话串行化该恢复过程。
## 验证
单元测试固定了按安全侧原则进行的分类、类型化参数验证、分组、屏障、注册表替换后拒绝尚未开始的调用、滚动上限、独立执行对象、中间件顺序、有序结果与上下文、中止排空,以及调度器故障后的完全停稳。第一方测试固定了每项并行声明。
单元测试固定了按安全侧原则进行的分类、类型化参数验证、分组、屏障、注册表替换后拒绝尚未开始的调用、滚动上限、独立执行对象、中间件顺序、有序结果与上下文、中止排空,以及调度器故障后的完全停稳。第一方测试固定了每项并行声明,工作目录服务测试固定了按会话串行化并发目录变更。
快照测试固定了可见的多调用 transcript(文本记录):待处理调用可以重叠执行,已完成结果仍按模型顺序排列。PTC mode 测试固定了其串行边界。此调度属于确定性循环行为,因此无需依赖提供方的 e2e 测试。
+3 -1
View File
@@ -86,7 +86,9 @@ type McpResourceRequest =
/** One configured server's resource access, owned by its MCP connection plugin. */
interface McpResourceProvider {
/**
* Run an operation against one live connection generation.
* Run an operation against one live connection generation. The resource tools
* dispatch in parallel, so one provider may receive several concurrent requests;
* each must settle independently.
* @param request - MCP resource method and parameters.
* @param exec - caller identity and cancellation for this invocation.
* @returns the protocol result as lossless JSON.
+3 -1
View File
@@ -86,7 +86,9 @@ type McpResourceRequest =
/** One configured server's resource access, owned by its MCP connection plugin. */
interface McpResourceProvider {
/**
* Run an operation against one live connection generation.
* Run an operation against one live connection generation. The resource tools
* dispatch in parallel, so one provider may receive several concurrent requests;
* each must settle independently.
* @param request - MCP resource method and parameters.
* @param exec - caller identity and cancellation for this invocation.
* @returns the protocol result as lossless JSON.
@@ -32,6 +32,7 @@ export function apply(ctx: Context): void {
schema: { type: 'json' },
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
},
// Listing reads the Provider registry without mutating it.
isConcurrencySafe: () => true,
execute(_args, _exec): Promise<JsonValue> {
return Promise.resolve({ providers: ctx.cordisInspect.list() } as unknown as JsonValue)
@@ -5,6 +5,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import { describe, expect, it } from 'vitest'
import * as CordisInspectProviders from '../src/host.ts'
import * as CordisInspectTools from '../src/index.ts'
/**
* The Host inspect providers are one process-global set: the host entry
@@ -35,3 +36,14 @@ describe('the cordis-inspect-providers host entry', () => {
expect(ctx.cordisInspect.list()).toEqual([])
})
})
describe('the cordis inspect tools', () => {
it('declares both read-only tools parallel-safe', async () => {
const ctx = await host()
const fiber = ctx.plugin(CordisInspectTools)
await fiber
expect(ctx.tools.get('cordis_inspect_list')?.isConcurrencySafe?.({})).toBe(true)
expect(ctx.tools.get('cordis_inspect_query')?.isConcurrencySafe?.({ platform: 'host', provider: 'Service', method: 'get' })).toBe(true)
await fiber.dispose()
})
})
@@ -1,20 +0,0 @@
import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native'
import { Context } from '@deepseek-ai/cordis'
import { CordisInspectRegistryService } from '@deepseek-ai/dsh-cordis-host-runner'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import { describe, expect, it } from 'vitest'
import * as CordisInspectTools from '../src/index.ts'
describe('the cordis inspect tools', () => {
it('declares both read-only tools parallel-safe', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(NativeToolExecutor)
await ctx.plugin(CordisInspectRegistryService, 10_000)
await ctx.plugin(CordisInspectTools)
expect(ctx.tools.get('cordis_inspect_list')?.isConcurrencySafe?.({})).toBe(true)
expect(ctx.tools.get('cordis_inspect_query')?.isConcurrencySafe?.({ platform: 'host', provider: 'Service', method: 'get' })).toBe(true)
})
})
+2 -2
View File
@@ -21,8 +21,8 @@
en: a5a01d14e0bec814
zh: 409298cfafd91b05
/deepseek-ai-dsh-tool-fs-search/use-this-package/the-tools:
en: 1932fbef1abcac77
zh: 6d8060990b5f2f08
en: d4a0419665d3c3b3
zh: 7fe718b32401b4cd
/deepseek-ai-dsh-tool-fs-search/use-this-package/configuration:
en: 3e84e19a22aeb6c8
zh: 8ac44b21eb697f2f
+2
View File
@@ -52,6 +52,8 @@ A subprocess backend, then the tools; the spill backend is optional and makes ca
Routine budgets stay out of the model-facing schema: a model that needs surrounding context reads the matched file with `read`, and one that needs later results follows the returned spill locator's retrieval hint.
Both tools opt into concurrent scheduling with sibling parallel-safe calls. A search records no file observation, over-cap spilling runs in the model-ordered post-execute phase, and working-directory recovery is serialized per Session by the working-directory service.
### Configuration
`sampleOverCapGlobResults` is required; the remaining keys are optional search caps with the defaults below.
+2
View File
@@ -52,6 +52,8 @@ kind: "package-reference"
常规预算不进入面向模型的 schema:需要周边上下文的模型用 `read` 读取匹配文件,需要后续结果的模型遵循返回的 spill locator 检索提示。
两个工具都选择与同批并行安全调用并发调度。搜索不记录文件观察,超出上限的 spill 在按模型顺序执行的 post-execute 阶段运行,工作目录恢复由工作目录服务按会话串行化。
### 配置
`sampleOverCapGlobResults` 为必填;其余键是可选的搜索上限,默认值如下。
+2 -2
View File
@@ -18,8 +18,8 @@
en: 823e94910a15e2e1
zh: c23e0b605f32f0bd
/deepseek-ai-dsh-tool-jobs/use-this-package/the-three-tools:
en: 6f20f54e60bcf637
zh: 6279c685d9d0bb16
en: 618467d160f47332
zh: f064a74fe4b08d3e
/deepseek-ai-dsh-tool-jobs/use-this-package/completion-notices:
en: 99577924f3d65112
zh: 521dac5540f9c1b7
+2
View File
@@ -35,6 +35,8 @@ Load this plugin in any composition where the agent should start, observe, and s
The three tools return `{ text, job }`, `PublicJobSnapshot[]`, and `{ outcome: 'cancellation-requested' | 'already-finished', job }` respectively. A public snapshot carries id, kind, label, status with the live progress line or the terminal detail, and start/finish times, and omits ownership and ring offsets. All three render through generic UI cards: `read` for output and list, `execute` for kill.
`job_list` opts into concurrent scheduling with sibling parallel-safe calls because it reads a status snapshot. `job_output` stays exclusive because each read consumes the stream cursor and the one-time result delivery, and `job_kill` stays exclusive because it requests cancellation.
### Completion notices
When a job finishes, the owning agent receives `background job <id> (<kind>: <label>) finished [status: ...]. Output:` followed by the output it has not read and the job's final result, or `. No new output.` when there is none. A notice is inlined only when the complete text fits `inlineOutputBytes` (2048 bytes by default) and the producer's `outputLimitBytes`; otherwise it ends `. Read its output with job_output.` instead, so large output and large final results reach the model as a `job_output` result, which the tool pipeline's spill policy bounds. The label is its first line, bounded to 120 characters. A `job_output` call that collects the job while its notice is still pending withdraws the notice, and a notice that never reaches the model leaves its output for `job_output`. A busy agent has the notice injected into its next step — the turn cannot close while the inbox holds it, so several jobs settling together cost one step rather than one turn each. An idle agent is instead woken with a follow-up turn, because an unclaimed notice is a completion the model never learns about. Completions the model already collected get no notice: the registry reports a settlement that released a live `wait` as `awaited` — whether a `job_output` wait or a shell tool waiting on its own foreground command — and the plugin remembers the kills the model requested through `job_kill`; a settlement caused by owner or service teardown is skipped because nobody is left to read it. A settlement that lands after a user steer ended the wait but before that call's read carries the terminal state is delivered by that read, so the plugin withholds the notice instead of reporting the completion twice.
+2
View File
@@ -35,6 +35,8 @@ kind: "package-reference"
三个工具依次返回 `{ text, job }`、`PublicJobSnapshot[]` 与 `{ outcome: 'cancellation-requested' | 'already-finished', job }`。公共快照携带 id、kind、label、带实时进度行或终态 detail 的状态,以及开始/结束时间,并省略归属与环偏移。三个工具都通过通用 UI 卡片渲染:output 和 list 用 `read`,kill 用 `execute`。
`job_list` 选择与同批并行安全调用并发调度,因为它读取的是状态快照。`job_output` 仍按独占方式执行,因为每次读取都会消费流游标和一次性的结果投递;`job_kill` 仍按独占方式执行,因为它会请求取消。
### 完成通知
任务完成时,拥有它的 agent 会收到 `background job <id> (<kind>: <label>) finished [status: ...]. Output:`,其后是它尚未读取的输出和任务的最终结果;没有输出时则以 `. No new output.` 结尾。只有完整文本同时不超过 `inlineOutputBytes`(默认 2048 字节)和生产方的 `outputLimitBytes` 时,通知才会内联;否则改以 `. Read its output with job_output.` 结尾,因此较大的输出和较大的最终结果会以 `job_output` 结果的形式抵达模型,由工具管线的 spill 策略限制其大小。label 取其首行,上限 120 个字符。在通知仍待领时收走该任务的 `job_output` 调用会撤回该通知;从未抵达模型的通知会把输出留给 `job_output`。繁忙的 agent 会在下一步收到注入的通知——inbox 尚有内容时轮次无法结束,因此同时结算的多个任务只花掉一步,而不是各占一轮。空闲的 agent 则被一个 follow-up 轮次唤醒,因为无人领取的通知就是模型永远不会知道的完成。模型已经收走的完成不再通知:注册表把释放了存活 `wait` 的结算报告为 `awaited`——无论是 `job_output` 的等待,还是 shell 工具在等待自己的前台命令——插件则记着模型经 `job_kill` 请求的杀停;所有者或服务 teardown 导致的结算会被跳过,因为没有人能读它。若结算发生在用户 steer 结束等待之后、该次调用的读取之前,终态由那次读取携带,插件因此不再发出通知,避免把同一个完成报告两次。
@@ -580,8 +580,10 @@ describe('job_list', () => {
it('is the only parallel-safe job tool', async () => {
const { ctx } = await setup()
expect(ctx.tools.get('job_list')?.isConcurrencySafe?.({})).toBe(true)
expect(ctx.tools.get('job_output')?.isConcurrencySafe).toBeUndefined()
expect(ctx.tools.get('job_kill')?.isConcurrencySafe).toBeUndefined()
for (const name of ['job_output', 'job_kill']) {
expect(ctx.tools.get(name)).toBeDefined()
expect(ctx.tools.get(name)).not.toHaveProperty('isConcurrencySafe')
}
})
it('lists caller-visible jobs and renders the empty case', async () => {
+3 -1
View File
@@ -25,7 +25,9 @@ export type McpResourceRequest =
/** One configured server's resource access, owned by its MCP connection plugin. */
export interface McpResourceProvider {
/**
* Run an operation against one live connection generation.
* Run an operation against one live connection generation. The resource tools
* dispatch in parallel, so one provider may receive several concurrent requests;
* each must settle independently.
* @param request - MCP resource method and parameters.
* @param exec - caller identity and cancellation for this invocation.
* @returns the protocol result as lossless JSON.
+1 -1
View File
@@ -29,13 +29,13 @@ const output = {
* @returns the effect disposer that removes all three tools synchronously.
*/
export function registerResourceTools(ctx: Context, request: RequestResource): () => void {
// All three tools are read-only and parallel-safe; `McpResourceProvider.request` settles concurrent requests independently.
const dispose = ctx.effect(function* () {
yield ctx.tools.register(defineTool({
name: 'list_mcp_resources',
description: 'List resources available from an MCP server.',
parameters: listParameters,
output,
// Resource requests do not mutate parent-agent state; the MCP client matches concurrent responses by request id.
isConcurrencySafe: () => true,
execute: (args, exec) => request(args.server, {
method: 'resources/list', ...args.cursor === undefined ? {} : { cursor: args.cursor },
@@ -126,8 +126,10 @@ describe('Schedule model tools', () => {
const parallel = (name: string, args: Record<string, unknown>) => test.ctx.tools.get(name, test.agent)?.isConcurrencySafe?.(args)
expect(parallel('schedule_list', {})).toBe(true)
expect(parallel('schedule_view', { id: 'task' })).toBe(true)
expect(test.ctx.tools.get('schedule_create', test.agent)?.isConcurrencySafe).toBeUndefined()
expect(test.ctx.tools.get('schedule_edit', test.agent)?.isConcurrencySafe).toBeUndefined()
for (const name of ['schedule_create', 'schedule_edit']) {
expect(test.ctx.tools.get(name, test.agent)).toBeDefined()
expect(test.ctx.tools.get(name, test.agent)).not.toHaveProperty('isConcurrencySafe')
}
const unrelated = agentFor(test.ctx, 'unrelated')
for (const name of TOOL_NAMES) {
expect(test.ctx.tools.get(name)).toBeUndefined()
@@ -127,6 +127,7 @@ describe('list_subagent_models', () => {
expect(schemas.find(schema => schema.name === 'discovery_subagent')!.parameters)
.not.toHaveProperty('properties.model')
await sibling.dispose()
expect(ctx.tools.get('list_subagent_models')).toBeDefined()
expect(ctx.tools.get('list_subagent_models')?.isConcurrencySafe?.({})).toBe(true)
await owner.dispose()
expect(ctx.tools.get('list_subagent_models')).toBeUndefined()
@@ -266,8 +266,10 @@ describe('dsh-tool-team', () => {
const teamTool = (name: string) => ctx.tools.get(name, scopeOf(lead.ctx))
expect(teamTool('team_task_get')?.isConcurrencySafe?.({ task_id: '1' })).toBe(true)
expect(teamTool('team_task_list')?.isConcurrencySafe?.({})).toBe(true)
expect(teamTool('team_task_create')?.isConcurrencySafe).toBeUndefined()
expect(teamTool('team_task_update')?.isConcurrencySafe).toBeUndefined()
for (const name of ['team_task_create', 'team_task_update']) {
expect(teamTool(name)).toBeDefined()
expect(teamTool(name)).not.toHaveProperty('isConcurrencySafe')
}
const leadPrompt = renderPrompt(leadAssembly)
expect(leadPrompt).toContain('Agent Teams is available in this session')
expect(leadPrompt).toContain('Task readiness never starts an owner')