feat(todo): make the parallel in_progress policy configurable
Whether concurrent active tasks are legitimate depends on runtime concurrency the tool cannot observe, but whether a deployment's agents ever fan out is knowable at composition time. `allowParallelInProgress` (default true) therefore replaces the hardcoded policy: the flag moves the model-facing instruction and the accepted input together, so a deployment running strictly sequential agents can restore the single-active discipline from cordis.yml. The durable-log invariant does not follow the flag. A log written while parallel work was allowed must still replay after a deployment tightens the policy, so the invariant stays silent on the active count.
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/todo/tool-todo/README.md
|
||||
README.md: 48d221b696dc692eeee072ce4a47b845b5fac9c7
|
||||
README.zh.md: c64727f66dffaf670b4f997718e76b769c3de299
|
||||
README.md: a9191fe4f5654bcfe64090625d5e6cc22b0934bc
|
||||
README.zh.md: 2d4309c643deedda5fb02d4380c4ad548d0d605d
|
||||
|
||||
@@ -14,9 +14,15 @@ Registers one tool, `todo_write(todos: [{ content, status }])`, on `ctx.tools`.
|
||||
|
||||
The list belongs to the ONE agent session that called the tool. There is no subagent/shared/swarm scope: a non-agent caller (no `exec.agent`) has nowhere to write the list and is rejected. This is a deliberate scope limit — see the Agent Note.
|
||||
|
||||
## Configuration
|
||||
|
||||
`allowParallelInProgress` (default `true`) decides whether several todos may be `in_progress` at once. It is a deployment choice, not a fixed rule: whether concurrent active tasks are legitimate depends on runtime concurrency the tool cannot observe, so a deployment whose agents never fan out can restore the single-active discipline from cordis.yml.
|
||||
|
||||
The flag moves the model-facing instruction and the accepted input together — `true` asks the model to mark every actively worked task and accepts any number, `false` asks for exactly one and rejects a call marking more with `Error: invalid todos: at most one task may be in_progress (got <n>)`. The durable-log invariant does NOT follow it: a log written while parallel work was allowed must still replay after a deployment tightens the policy, so the invariant stays silent on the active count.
|
||||
|
||||
## Validation
|
||||
|
||||
Beyond the schema's type/required/enum checks, `execute` rejects an empty or duplicate `content`, and any item key beyond `content`/`status` — an extended item shape (ids, nesting) fails loud instead of silently flattening, keeping the logged snapshot equal to what the model believes it wrote. Any number of tasks may be `in_progress` at once — parallel work (concurrent subagents, background commands) legitimately runs several tasks simultaneously. Ordering and the discipline of keeping the list current are left to the model via the tool description.
|
||||
Beyond the schema's type/required/enum checks, `execute` rejects an empty or duplicate `content`, and any item key beyond `content`/`status` — an extended item shape (ids, nesting) fails loud instead of silently flattening, keeping the logged snapshot equal to what the model believes it wrote. How many tasks may be `in_progress` at once is the deployment's call (§ Configuration): the default allows several, because parallel work (concurrent subagents, background commands) legitimately runs several tasks simultaneously. Ordering and the discipline of keeping the list current are left to the model via the tool description.
|
||||
|
||||
## Rendering
|
||||
|
||||
@@ -50,7 +56,7 @@ Prefix-stable while the definition and visibility are unchanged. Plugin lifecycl
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Each assistant tool call retains the entire replacement list in its arguments. Success returns exactly `Updated todo list: <pending> pending, <inProgress> in progress, <completed> completed.` Stable failures are ``Error: invalid todo: `content` must be a non-empty string``, `Error: invalid todos: duplicate content "<content>"`, and `Error: todo_write requires an owning agent session`. The full `todo/write` session event is UI and replay state, not a second model message.
|
||||
Each assistant tool call retains the entire replacement list in its arguments. Success returns exactly `Updated todo list: <pending> pending, <inProgress> in progress, <completed> completed.` Stable failures are ``Error: invalid todo: `content` must be a non-empty string``, `Error: invalid todos: duplicate content "<content>"`, `Error: todo_write requires an owning agent session`, and — only where the deployment set `allowParallelInProgress: false` — `Error: invalid todos: at most one task may be in_progress (got <n>)`. The full `todo/write` session event is UI and replay state, not a second model message.
|
||||
|
||||
#### Token effect
|
||||
|
||||
|
||||
@@ -14,9 +14,15 @@
|
||||
|
||||
该列表属于调用工具的唯一 agent 会话。不存在 subagent/共享/swarm scope:非 agent 调用方(没有 `exec.agent`)无处写入列表,因此会被拒绝。这是有意设置的 scope 限制,详见 Agent Note。
|
||||
|
||||
## 配置
|
||||
|
||||
`allowParallelInProgress`(默认 `true`)决定是否允许多个 todo 同时处于 `in_progress`。这是部署层的选择而非固定规则:并发的活跃任务是否合理,取决于工具无法观测的运行时并发情况,因此 agent 从不并行展开的部署可以从 cordis.yml 恢复单活跃项纪律。
|
||||
|
||||
该开关会同时改变面向模型的指令与接受的输入——`true` 要求模型标记每个正在推进的任务并接受任意数量;`false` 要求恰好一个,并以 `Error: invalid todos: at most one task may be in_progress (got <n>)` 拒绝标记更多的调用。持久日志不变式**不**跟随它:在允许并行时写下的日志,在部署收紧策略之后仍必须可回放,因此不变式对活跃数量保持沉默。
|
||||
|
||||
## 验证
|
||||
|
||||
除 schema 的类型/必填/枚举检查外,`execute` 还会拒绝空或重复的 `content`,以及 `content`/`status` 之外的任何条目键——扩展条目形状(id、嵌套)会响亮失败而不是被静默压平,保证落日志的快照与模型自认为写入的内容一致。任意数量的任务可以同时处于 `in_progress`——并行工作(并发 subagent、后台命令)确实会同时推进多个任务。顺序与保持列表最新的纪律由模型根据工具描述负责。
|
||||
除 schema 的类型/必填/枚举检查外,`execute` 还会拒绝空或重复的 `content`,以及 `content`/`status` 之外的任何条目键——扩展条目形状(id、嵌套)会响亮失败而不是被静默压平,保证落日志的快照与模型自认为写入的内容一致。同时可以有多少任务处于 `in_progress` 由部署决定(见 § 配置):默认允许多个,因为并行工作(并发 subagent、后台命令)确实会同时推进多个任务。顺序与保持列表最新的纪律由模型根据工具描述负责。
|
||||
|
||||
## 渲染
|
||||
|
||||
@@ -50,7 +56,7 @@
|
||||
|
||||
#### 模型所见内容
|
||||
|
||||
每个 assistant 工具调用都会在参数中保留整个替换列表。成功时精确返回 `Updated todo list: <pending> pending, <inProgress> in progress, <completed> completed.`。稳定失败文本为 ``Error: invalid todo: `content` must be a non-empty string``、`Error: invalid todos: duplicate content "<content>"` 和 `Error: todo_write requires an owning agent session`。完整 `todo/write` 会话事件是 UI 与回放状态,而非第二条模型消息。
|
||||
每个 assistant 工具调用都会在参数中保留整个替换列表。成功时精确返回 `Updated todo list: <pending> pending, <inProgress> in progress, <completed> completed.`。稳定失败文本为 ``Error: invalid todo: `content` must be a non-empty string``、`Error: invalid todos: duplicate content "<content>"`、`Error: todo_write requires an owning agent session`,以及——仅在部署设置了 `allowParallelInProgress: false` 时——`Error: invalid todos: at most one task may be in_progress (got <n>)`。完整 `todo/write` 会话事件是 UI 与回放状态,而非第二条模型消息。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { z } from 'zod'
|
||||
import z from 'schemastery'
|
||||
import { z as zod } from 'zod'
|
||||
import type { ZodType } from 'zod'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { TodoItem } from '@deepseek-ai/dsh-session'
|
||||
@@ -24,31 +25,73 @@ export const inject = ['tools']
|
||||
/** The valid {@link TodoItem} statuses, as a runtime set for input narrowing. */
|
||||
const STATUSES = ['pending', 'in_progress', 'completed'] as const
|
||||
|
||||
const DESCRIPTION =
|
||||
/** Model-facing todo tool configuration. */
|
||||
export interface Config {
|
||||
/**
|
||||
* Whether several todos may be `in_progress` at once (default true). True suits a deployment
|
||||
* whose agents run work concurrently — subagents, background commands, workflow fan-out — and
|
||||
* the description then instructs the model to mark every actively worked task. False restores
|
||||
* the single-active discipline: the description asks for exactly one, and a call marking more
|
||||
* is rejected.
|
||||
*/
|
||||
allowParallelInProgress?: boolean
|
||||
}
|
||||
|
||||
/** Schemastery configuration for the todo tool consumer. */
|
||||
export const Config: z<Config> = z.object({
|
||||
allowParallelInProgress: z.boolean().default(true),
|
||||
})
|
||||
|
||||
const DESCRIPTION_HEAD =
|
||||
'Record and update a structured task list for the current work. Send the ENTIRE '
|
||||
+ 'list every call — it REPLACES the previous list (there are no partial updates, '
|
||||
+ 'no per-item edits). Use it to plan multi-step work and show progress: add one '
|
||||
+ 'todo per concrete step before you start. Mark every todo being actively worked '
|
||||
+ 'todo per concrete step before you start. '
|
||||
|
||||
const DESCRIPTION_PARALLEL =
|
||||
'Mark every todo being actively worked '
|
||||
+ 'on `in_progress` — several at once when work genuinely runs in parallel (e.g. '
|
||||
+ 'concurrent subagents or background commands), one for sequential work; while '
|
||||
+ 'work remains, at least one task should be `in_progress`. Mark a todo '
|
||||
+ 'work remains, at least one task should be `in_progress`. '
|
||||
|
||||
const DESCRIPTION_SINGLE =
|
||||
'Keep AT MOST ONE todo `in_progress` at a '
|
||||
+ 'time; while work remains, exactly one active task should be `in_progress`. '
|
||||
|
||||
const DESCRIPTION_TAIL =
|
||||
'Mark a todo '
|
||||
+ '`completed` the moment it is done (do not batch completions), and allow no '
|
||||
+ '`in_progress` item only once all work is complete. Skip the list for trivial '
|
||||
+ 'single-step tasks. Statuses: `pending` (not started), `in_progress` (being '
|
||||
+ 'worked on now), `completed` (finished).'
|
||||
|
||||
/**
|
||||
* Validate the value constraints the ParameterSchemaSpec can't express and build the canonical {@link
|
||||
* TodoItem}[]: trimmed non-empty unique content. Any number of items may be in_progress —
|
||||
* parallel work (subagents, background commands) legitimately runs several tasks at once. The
|
||||
* registry has already enforced the status enum and rejected unknown item keys
|
||||
* (`additionalProperties: false` — the logged snapshot must equal what the model believes it
|
||||
* wrote, so a nested/extended item shape fails loud at the schema boundary instead of silently
|
||||
* flattening); the cast below records that guarantee.
|
||||
* The model-facing description for one activation. The active-status clause is the only part that
|
||||
* varies, because it is the only instruction the parallel policy changes.
|
||||
* @param allowParallel - whether several todos may be `in_progress` at once.
|
||||
* @returns the composed tool description.
|
||||
*/
|
||||
function toTodoList(raw: { content: string; status: string }[]): TodoItem[] {
|
||||
function describe(allowParallel: boolean): string {
|
||||
return DESCRIPTION_HEAD
|
||||
+ (allowParallel ? DESCRIPTION_PARALLEL : DESCRIPTION_SINGLE)
|
||||
+ DESCRIPTION_TAIL
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the value constraints the ParameterSchemaSpec can't express and build the canonical {@link
|
||||
* TodoItem}[]: trimmed non-empty unique content, and at most one `in_progress` item unless the
|
||||
* deployment allows parallel work. The registry has already enforced the status enum and rejected
|
||||
* unknown item keys (`additionalProperties: false` — the logged snapshot must equal what the model
|
||||
* believes it wrote, so a nested/extended item shape fails loud at the schema boundary instead of
|
||||
* silently flattening); the cast below records that guarantee.
|
||||
* @param raw - the model-supplied list, already schema-checked.
|
||||
* @param allowParallel - whether several items may be `in_progress` at once.
|
||||
* @returns the canonical list.
|
||||
*/
|
||||
function toTodoList(raw: { content: string; status: string }[], allowParallel: boolean): TodoItem[] {
|
||||
const todos: TodoItem[] = []
|
||||
const seen = new Set<string>()
|
||||
let active = 0
|
||||
for (const item of raw) {
|
||||
const content = item.content.trim()
|
||||
if (content.length === 0) {
|
||||
@@ -58,22 +101,32 @@ function toTodoList(raw: { content: string; status: string }[]): TodoItem[] {
|
||||
throw new Error(`invalid todos: duplicate content ${JSON.stringify(content)}`)
|
||||
}
|
||||
seen.add(content)
|
||||
if (item.status === 'in_progress') active++
|
||||
todos.push({ content, status: item.status as TodoItem['status'] })
|
||||
}
|
||||
if (!allowParallel && active > 1) {
|
||||
throw new Error(`invalid todos: at most one task may be in_progress (got ${active})`)
|
||||
}
|
||||
return todos
|
||||
}
|
||||
|
||||
/** Wire payload schema of the `todos` projection (whole list or pre-first-write null). */
|
||||
const todosProjectionSchema: ZodType<TodoItem[] | null> = z.union([
|
||||
z.array(z.object({
|
||||
content: z.string(),
|
||||
status: z.union([z.literal('pending'), z.literal('in_progress'), z.literal('completed')]),
|
||||
const todosProjectionSchema: ZodType<TodoItem[] | null> = zod.union([
|
||||
zod.array(zod.object({
|
||||
content: zod.string(),
|
||||
status: zod.union([zod.literal('pending'), zod.literal('in_progress'), zod.literal('completed')]),
|
||||
})),
|
||||
z.null(),
|
||||
zod.null(),
|
||||
])
|
||||
|
||||
/** Register the `todo_write` tool on `ctx.tools` and, when the session-projection seam is composed, the `todos` unit. */
|
||||
export function apply(ctx: Context): void {
|
||||
/**
|
||||
* Register the `todo_write` tool on `ctx.tools` and, when the session-projection seam is composed,
|
||||
* the `todos` unit.
|
||||
* @param ctx - registrant context carrying the tool registry.
|
||||
* @param config - deployment's todo policy; defaults to allowing parallel active items.
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config = {}): void {
|
||||
const allowParallel = config.allowParallelInProgress ?? true
|
||||
// The unit child activates only when a projection registry is composed
|
||||
// (headless assemblies without the seam stay unaffected). Standing-plan fold:
|
||||
// latest whole todo/write list, cleared by the next turn/start (turn/end keeps
|
||||
@@ -96,7 +149,7 @@ export function apply(ctx: Context): void {
|
||||
})
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'todo_write',
|
||||
description: DESCRIPTION,
|
||||
description: describe(allowParallel),
|
||||
parameters: {
|
||||
todos: {
|
||||
type: 'array',
|
||||
@@ -152,7 +205,7 @@ export function apply(ctx: Context): void {
|
||||
}],
|
||||
},
|
||||
execute(args, exec) {
|
||||
const todos = toTodoList(args.todos)
|
||||
const todos = toTodoList(args.todos, allowParallel)
|
||||
if (!exec.agent) {
|
||||
// The list is per-agent-session state; a non-agent caller (no owning
|
||||
// session) has nowhere to write it. Reject rather than silently no-op.
|
||||
|
||||
@@ -12,7 +12,15 @@ export const name = 'tool-todo-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/** Validate one whole-list todo snapshot before it reaches the durable log. */
|
||||
/**
|
||||
* Validate one whole-list todo snapshot before it reaches the durable log.
|
||||
*
|
||||
* Deliberately silent on how many items are `in_progress`. That is the tool's
|
||||
* per-deployment policy (`Config.allowParallelInProgress`), not a durable-shape
|
||||
* rule: a log written while parallel work was allowed must still replay after a
|
||||
* deployment tightens the policy, so tying the invariant to the current config
|
||||
* would reject history that was valid when it was written.
|
||||
*/
|
||||
function validateTodos(value: unknown, fail: InvariantFailure): void {
|
||||
if (!Array.isArray(value)) fail('todo/write todos must be an array')
|
||||
const seen = new Set<string>()
|
||||
|
||||
126
packages/todo/tool-todo/tests/loader-composition.spec.ts
Normal file
126
packages/todo/tool-todo/tests/loader-composition.spec.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
// Proves `allowParallelInProgress` is real configurability and not a constant:
|
||||
// the flag is set in a cordis.yml booted through the real Loader, and both faces
|
||||
// it controls — the model-facing description and the accepted input — follow it.
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import Include from '@cordisjs/plugin-include'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import * as ToolTodo from '@deepseek-ai/dsh-tool-todo'
|
||||
|
||||
let root: string | undefined
|
||||
let context: Context | undefined
|
||||
|
||||
afterEach(async () => {
|
||||
await context?.fiber.dispose()
|
||||
context = undefined
|
||||
if (root !== undefined) await rm(root, { recursive: true, force: true })
|
||||
root = undefined
|
||||
})
|
||||
|
||||
function agent(ctx: Context): Agent {
|
||||
const scope = ctx.plugin(() => {})
|
||||
const id = SessionId('todo-loader-agent')
|
||||
const value: Agent = {
|
||||
id, options: {}, session: new Session(id), status: 'idle', acceptsNextStep: false, ctx: scope.ctx,
|
||||
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
}
|
||||
ctx.agents.register(value)
|
||||
return value
|
||||
}
|
||||
|
||||
function resultText(result: { content: { type: string; text?: string }[] }): string {
|
||||
return result.content.filter(block => block.type === 'text').map(block => block.text).join('')
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot a cordis.yml carrying the given tool-todo config block.
|
||||
* @param configLines - YAML lines nested under the tool's `config:` key.
|
||||
* @returns the booted context.
|
||||
*/
|
||||
async function boot(configLines: readonly string[]): Promise<Context> {
|
||||
root = await mkdtemp(join(tmpdir(), 'dsh-todo-loader-'))
|
||||
const configPath = join(root, 'cordis.yml')
|
||||
await writeFile(configPath, [
|
||||
"- name: '@deepseek-ai/dsh-agent'",
|
||||
"- name: '@deepseek-ai/dsh-system-prompt'",
|
||||
"- name: '@deepseek-ai/dsh-tools'",
|
||||
"- name: '@deepseek-ai/dsh-tool-todo'",
|
||||
...configLines.length > 0 ? [' config:', ...configLines] : [],
|
||||
'',
|
||||
].join('\n'))
|
||||
|
||||
const ctx = new Context()
|
||||
ctx.baseUrl = pathToFileURL(root).href + '/'
|
||||
await ctx.plugin(Loader)
|
||||
ctx.loader.builtins.include = Include
|
||||
const modules = new Map<string, unknown>([
|
||||
['@deepseek-ai/dsh-agent', AgentRegistry],
|
||||
['@deepseek-ai/dsh-system-prompt', SystemPrompt],
|
||||
['@deepseek-ai/dsh-tools', ToolRegistry],
|
||||
['@deepseek-ai/dsh-tool-todo', ToolTodo],
|
||||
])
|
||||
ctx.loader.internal = {
|
||||
version: 'v2',
|
||||
async import(specifier: string) {
|
||||
if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`)
|
||||
return modules.get(specifier)
|
||||
},
|
||||
} as unknown as NonNullable<typeof ctx.loader.internal>
|
||||
await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(configPath).href } })
|
||||
await ctx.loader.await()
|
||||
context = ctx
|
||||
return ctx
|
||||
}
|
||||
|
||||
const PARALLEL_TODOS = [
|
||||
{ content: 'run subagent a', status: 'in_progress' },
|
||||
{ content: 'run subagent b', status: 'in_progress' },
|
||||
]
|
||||
|
||||
describe('tool-todo real Loader composition through cordis.yml', () => {
|
||||
it('allowParallelInProgress: false narrows the description and rejects a parallel write', async () => {
|
||||
const ctx = await boot([' allowParallelInProgress: false'])
|
||||
const description = ctx.tools.schemas().find(s => s.name === 'todo_write')?.description ?? ''
|
||||
expect(description).toContain('Keep AT MOST ONE todo `in_progress`')
|
||||
expect(description).not.toContain('several at once')
|
||||
|
||||
const owner = agent(ctx)
|
||||
const result = await ctx.tools.execute({
|
||||
signal: new AbortController().signal,
|
||||
callId: CallId('parallel'),
|
||||
name: 'todo_write',
|
||||
arguments: { todos: PARALLEL_TODOS },
|
||||
agent: owner,
|
||||
})
|
||||
expect(result.isError).toBe(true)
|
||||
expect(resultText(result)).toContain('at most one task may be in_progress')
|
||||
expect(owner.session.events.some(e => e.type === 'todo/write')).toBe(false)
|
||||
}, 30_000)
|
||||
|
||||
it('the omitted default keeps the parallel policy end to end', async () => {
|
||||
const ctx = await boot([])
|
||||
const description = ctx.tools.schemas().find(s => s.name === 'todo_write')?.description ?? ''
|
||||
expect(description).toContain('several at once when work genuinely runs in parallel')
|
||||
|
||||
const owner = agent(ctx)
|
||||
const result = await ctx.tools.execute({
|
||||
signal: new AbortController().signal,
|
||||
callId: CallId('parallel-default'),
|
||||
name: 'todo_write',
|
||||
arguments: { todos: PARALLEL_TODOS },
|
||||
agent: owner,
|
||||
})
|
||||
expect(result.isError).toBe(false)
|
||||
expect(owner.session.events.findLast(e => e.type === 'todo/write')?.data.todos).toEqual(PARALLEL_TODOS)
|
||||
}, 30_000)
|
||||
})
|
||||
@@ -26,11 +26,11 @@ function agentWithSession(id = 'parent-1'): Agent & { session: Session } {
|
||||
return { id: SessionId(id), session } as unknown as Agent & { session: Session }
|
||||
}
|
||||
|
||||
async function setup(): Promise<Context> {
|
||||
async function setup(config: tool.Config = {}): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(tool)
|
||||
await ctx.plugin(tool, config)
|
||||
return ctx
|
||||
}
|
||||
|
||||
@@ -140,6 +140,62 @@ describe('dsh-tool-todo', () => {
|
||||
expect(agent.session.events.findLast(e => e.type === 'todo/write')!.data.todos).toEqual(todos)
|
||||
})
|
||||
|
||||
describe('allowParallelInProgress: false', () => {
|
||||
const parallel = [
|
||||
{ content: 'run subagent a', status: 'in_progress' },
|
||||
{ content: 'run subagent b', status: 'in_progress' },
|
||||
]
|
||||
|
||||
it('rejects a call marking several items in_progress', async () => {
|
||||
const ctx = await setup({ allowParallelInProgress: false })
|
||||
const agent = agentWithSession('single-active')
|
||||
const result = await callTodo(ctx, { todos: parallel }, { agent })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('at most one task may be in_progress')
|
||||
// A rejected call must not reach the durable log.
|
||||
expect(agent.session.events.some(e => e.type === 'todo/write')).toBe(false)
|
||||
})
|
||||
|
||||
it('still accepts one active item', async () => {
|
||||
const ctx = await setup({ allowParallelInProgress: false })
|
||||
const todos: TodoItem[] = [
|
||||
{ content: 'run subagent a', status: 'in_progress' },
|
||||
{ content: 'run subagent b', status: 'pending' },
|
||||
]
|
||||
const result = await callTodo(ctx, { todos })
|
||||
expect(result.isError).toBe(false)
|
||||
})
|
||||
|
||||
it('an explicit true accepts a parallel write, like the omitted default', async () => {
|
||||
const ctx = await setup({ allowParallelInProgress: true })
|
||||
const result = await callTodo(ctx, { todos: parallel })
|
||||
expect(result.isError).toBe(false)
|
||||
})
|
||||
|
||||
it('defaults to parallel for a direct apply, which bypasses the schema default', async () => {
|
||||
// Composing through ctx.plugin lets schemastery fill the field; a caller
|
||||
// invoking apply() itself hands over a config object with it absent, so
|
||||
// the policy default has to hold on that path too.
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
tool.apply(ctx, {})
|
||||
const result = await callTodo(ctx, { todos: parallel })
|
||||
expect(result.isError).toBe(false)
|
||||
})
|
||||
|
||||
it('instructs the model to keep at most one active, and the default instructs parallel', async () => {
|
||||
const single = await setup({ allowParallelInProgress: false })
|
||||
const singleDesc = single.tools.schemas().find(s => s.name === 'todo_write')!.description
|
||||
expect(singleDesc).toContain('Keep AT MOST ONE todo `in_progress`')
|
||||
expect(singleDesc).not.toContain('several at once')
|
||||
|
||||
const parallelDesc = (await setup()).tools.schemas().find(s => s.name === 'todo_write')!.description
|
||||
expect(parallelDesc).toContain('several at once when work genuinely runs in parallel')
|
||||
expect(parallelDesc).not.toContain('AT MOST ONE')
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ label: 'empty content', todos: [{ content: ' ', status: 'pending' }], fragment: 'non-empty' },
|
||||
{ label: 'duplicate content', todos: [{ content: 'dup', status: 'pending' }, { content: 'dup', status: 'completed' }], fragment: 'duplicate' },
|
||||
|
||||
Reference in New Issue
Block a user