fix(todo): require parallel policy choice
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: a9191fe4f5654bcfe64090625d5e6cc22b0934bc
|
||||
README.zh.md: 2d4309c643deedda5fb02d4380c4ad548d0d605d
|
||||
README.md: 76ba6a089e92635618f70747e6ee54260d4c8fbc
|
||||
README.zh.md: 5c5b84f5730d5dd815d429b7ee6c12fc11554115
|
||||
|
||||
@@ -16,13 +16,13 @@ The list belongs to the ONE agent session that called the tool. There is no suba
|
||||
|
||||
## 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.
|
||||
`allowParallelInProgress` is required: every composition must choose 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. Use `true` for agents that may fan out work and `false` to enforce the single-active discipline.
|
||||
|
||||
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. 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.
|
||||
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): a composition that chooses `true` permits parallel work (concurrent subagents, background commands) to mark several tasks simultaneously. Ordering and the discipline of keeping the list current are left to the model via the tool description.
|
||||
|
||||
## Rendering
|
||||
|
||||
|
||||
@@ -16,13 +16,13 @@
|
||||
|
||||
## 配置
|
||||
|
||||
`allowParallelInProgress`(默认 `true`)决定是否允许多个 todo 同时处于 `in_progress`。这是部署层的选择而非固定规则:并发的活跃任务是否合理,取决于工具无法观测的运行时并发情况,因此 agent 从不并行展开的部署可以从 cordis.yml 恢复单活跃项纪律。
|
||||
`allowParallelInProgress` 是必填项:每个组合都必须选择是否允许多个 todo 同时处于 `in_progress`。这是部署层的选择而非固定规则:并发的活跃任务是否合理,取决于工具无法观测的运行时并发情况。可能并行展开工作的 agent 使用 `true`,`false` 则强制执行单活跃项纪律。
|
||||
|
||||
该开关会同时改变面向模型的指令与接受的输入——`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` 由部署决定(见 § 配置):选择 `true` 的组合允许并行工作(并发 subagent、后台命令)同时将多个任务标记为 `in_progress`。顺序与保持列表最新的纪律由模型根据工具描述负责。
|
||||
|
||||
## 渲染
|
||||
|
||||
|
||||
@@ -28,18 +28,18 @@ const STATUSES = ['pending', 'in_progress', 'completed'] as const
|
||||
/** 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.
|
||||
* Required deployment choice for whether several todos may be `in_progress` at once. True suits
|
||||
* agents that 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
|
||||
allowParallelInProgress: boolean
|
||||
}
|
||||
|
||||
/** Schemastery configuration for the todo tool consumer. */
|
||||
export const Config: z<Config> = z.object({
|
||||
allowParallelInProgress: z.boolean().default(true),
|
||||
allowParallelInProgress: z.boolean().required(),
|
||||
})
|
||||
|
||||
const DESCRIPTION_HEAD =
|
||||
@@ -123,10 +123,10 @@ const todosProjectionSchema: ZodType<TodoItem[] | null> = zod.union([
|
||||
* 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.
|
||||
* @param config - deployment's explicit todo policy.
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config = {}): void {
|
||||
const allowParallel = config.allowParallelInProgress ?? true
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
const allowParallel = config.allowParallelInProgress
|
||||
// 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
|
||||
|
||||
@@ -18,7 +18,7 @@ async function harness(adapter: MockAdapter): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(ToolTodo)
|
||||
await ctx.plugin(ToolTodo, { allowParallelInProgress: true })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
return ctx
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ 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 { Context, FiberState } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import Include from '@cordisjs/plugin-include'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
@@ -60,6 +60,7 @@ async function boot(configLines: readonly string[]): Promise<Context> {
|
||||
].join('\n'))
|
||||
|
||||
const ctx = new Context()
|
||||
context = ctx
|
||||
ctx.baseUrl = pathToFileURL(root).href + '/'
|
||||
await ctx.plugin(Loader)
|
||||
ctx.loader.builtins.include = Include
|
||||
@@ -78,7 +79,6 @@ async function boot(configLines: readonly string[]): Promise<Context> {
|
||||
} 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
|
||||
}
|
||||
|
||||
@@ -107,15 +107,15 @@ describe('tool-todo real Loader composition through cordis.yml', () => {
|
||||
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([])
|
||||
it('allowParallelInProgress: true permits a parallel write end to end', async () => {
|
||||
const ctx = await boot([' allowParallelInProgress: true'])
|
||||
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'),
|
||||
callId: CallId('parallel-enabled'),
|
||||
name: 'todo_write',
|
||||
arguments: { todos: PARALLEL_TODOS },
|
||||
agent: owner,
|
||||
@@ -123,4 +123,23 @@ describe('tool-todo real Loader composition through cordis.yml', () => {
|
||||
expect(result.isError).toBe(false)
|
||||
expect(owner.session.events.findLast(e => e.type === 'todo/write')?.data.todos).toEqual(PARALLEL_TODOS)
|
||||
}, 30_000)
|
||||
|
||||
it('fails loading when allowParallelInProgress is omitted', async () => {
|
||||
// loader.await() is all-settled; configuration failure leaves a FAILED
|
||||
// entry and escapes as a late rejection for the host boot to report.
|
||||
const rejections: unknown[] = []
|
||||
const onUnhandled = (err: unknown): void => { rejections.push(err) }
|
||||
process.on('unhandledRejection', onUnhandled)
|
||||
try {
|
||||
const ctx = await boot([])
|
||||
const entry = [...ctx.loader.entries()].find(e => e.options.name === '@deepseek-ai/dsh-tool-todo')
|
||||
expect(entry?.fiber?.state).toBe(FiberState.FAILED)
|
||||
for (let i = 0; i < 100 && rejections.length === 0; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
}
|
||||
expect(rejections.map(String).join('\n')).toContain('$.allowParallelInProgress missing required value')
|
||||
} finally {
|
||||
process.off('unhandledRejection', onUnhandled)
|
||||
}
|
||||
}, 30_000)
|
||||
})
|
||||
|
||||
@@ -42,7 +42,7 @@ async function harness(withTodoTool: boolean): Promise<Bench> {
|
||||
await ctx.plugin(UserInteractionService)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(SessionProjectionRegistry)
|
||||
if (withTodoTool) await ctx.plugin(ToolTodo)
|
||||
if (withTodoTool) await ctx.plugin(ToolTodo, { allowParallelInProgress: true })
|
||||
const session = ctx.sessions.create()
|
||||
ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
|
||||
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
@@ -116,7 +116,7 @@ describe('todos projection provider', () => {
|
||||
it('drops the key when the tool-todo fiber unloads (HMR safety)', async () => {
|
||||
const bench = await harness(false)
|
||||
seedMessage(bench.session)
|
||||
const fiber = await bench.ctx.plugin(ToolTodo)
|
||||
const fiber = await bench.ctx.plugin(ToolTodo, { allowParallelInProgress: true })
|
||||
expect((await bench.tailProjections())?.values).toEqual({ todos: null })
|
||||
await fiber.dispose()
|
||||
expect('todos' in ((await bench.tailProjections())?.values ?? {})).toBe(false)
|
||||
|
||||
@@ -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(config: tool.Config = {}): Promise<Context> {
|
||||
async function setup(allowParallelInProgress: boolean): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(tool, config)
|
||||
await ctx.plugin(tool, { allowParallelInProgress })
|
||||
return ctx
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ function text(result: { content: { type: string; text?: string }[] }): string {
|
||||
|
||||
describe('dsh-tool-todo', () => {
|
||||
it('registers a `todo_write` tool whose schema is an array of {content,status}', async () => {
|
||||
const ctx = await setup()
|
||||
const ctx = await setup(true)
|
||||
const schema = ctx.tools.schemas().find(s => s.name === 'todo_write')
|
||||
expect(schema).toBeDefined()
|
||||
const props = (schema!.parameters as { properties?: Record<string, unknown> }).properties ?? {}
|
||||
@@ -65,7 +65,7 @@ describe('dsh-tool-todo', () => {
|
||||
})
|
||||
|
||||
it('appends a todo/write event carrying the whole list to the calling session', async () => {
|
||||
const ctx = await setup()
|
||||
const ctx = await setup(true)
|
||||
const agent = agentWithSession('writer')
|
||||
const todos: TodoItem[] = [
|
||||
{ content: 'plan', status: 'in_progress' },
|
||||
@@ -85,7 +85,7 @@ describe('dsh-tool-todo', () => {
|
||||
})
|
||||
|
||||
it('stores the trimmed content (the dedupe/length key), not the raw input', async () => {
|
||||
const ctx = await setup()
|
||||
const ctx = await setup(true)
|
||||
const agent = agentWithSession('trim')
|
||||
const result = await callTodo(ctx, { todos: [{ content: ' plan the work ', status: 'pending' }] }, { agent })
|
||||
expect(result.isError).toBe(false)
|
||||
@@ -95,7 +95,7 @@ describe('dsh-tool-todo', () => {
|
||||
})
|
||||
|
||||
it('replaces the list on a second call (last-write-wins on the log)', async () => {
|
||||
const ctx = await setup()
|
||||
const ctx = await setup(true)
|
||||
const agent = agentWithSession('writer-2')
|
||||
await callTodo(ctx, { todos: [{ content: 'a', status: 'pending' }] }, { agent })
|
||||
await callTodo(ctx, { todos: [
|
||||
@@ -111,19 +111,19 @@ describe('dsh-tool-todo', () => {
|
||||
})
|
||||
|
||||
it('rejects a malformed status before execute runs (registry arg-validation)', async () => {
|
||||
const ctx = await setup()
|
||||
const ctx = await setup(true)
|
||||
const result = await callTodo(ctx, { todos: [{ content: 'x', status: 'doing' }] })
|
||||
expect(result.isError).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a non-array todos argument', async () => {
|
||||
const ctx = await setup()
|
||||
const ctx = await setup(true)
|
||||
const result = await callTodo(ctx, { todos: 'nope' })
|
||||
expect(result.isError).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts several in_progress items at once (parallel work)', async () => {
|
||||
const ctx = await setup()
|
||||
const ctx = await setup(true)
|
||||
const agent = agentWithSession('parallel')
|
||||
const todos: TodoItem[] = [
|
||||
{ content: 'run subagent a', status: 'in_progress' },
|
||||
@@ -147,7 +147,7 @@ describe('dsh-tool-todo', () => {
|
||||
]
|
||||
|
||||
it('rejects a call marking several items in_progress', async () => {
|
||||
const ctx = await setup({ allowParallelInProgress: false })
|
||||
const ctx = await setup(false)
|
||||
const agent = agentWithSession('single-active')
|
||||
const result = await callTodo(ctx, { todos: parallel }, { agent })
|
||||
expect(result.isError).toBe(true)
|
||||
@@ -157,7 +157,7 @@ describe('dsh-tool-todo', () => {
|
||||
})
|
||||
|
||||
it('still accepts one active item', async () => {
|
||||
const ctx = await setup({ allowParallelInProgress: false })
|
||||
const ctx = await setup(false)
|
||||
const todos: TodoItem[] = [
|
||||
{ content: 'run subagent a', status: 'in_progress' },
|
||||
{ content: 'run subagent b', status: 'pending' },
|
||||
@@ -166,31 +166,19 @@ describe('dsh-tool-todo', () => {
|
||||
expect(result.isError).toBe(false)
|
||||
})
|
||||
|
||||
it('an explicit true accepts a parallel write, like the omitted default', async () => {
|
||||
const ctx = await setup({ allowParallelInProgress: true })
|
||||
it('an explicit true accepts a parallel write', async () => {
|
||||
const ctx = await setup(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 })
|
||||
it('instructs the model to keep at most one active, while true instructs parallel', async () => {
|
||||
const single = await setup(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
|
||||
const parallelDesc = (await setup(true)).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')
|
||||
})
|
||||
@@ -201,21 +189,21 @@ describe('dsh-tool-todo', () => {
|
||||
{ label: 'duplicate content', todos: [{ content: 'dup', status: 'pending' }, { content: 'dup', status: 'completed' }], fragment: 'duplicate' },
|
||||
{ label: 'unknown item keys', todos: [{ content: 'a', status: 'pending', children: [] }], fragment: 'not a declared property' },
|
||||
])('rejects $label as an isError result', async ({ todos, fragment }) => {
|
||||
const ctx = await setup()
|
||||
const ctx = await setup(true)
|
||||
const result = await callTodo(ctx, { todos })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain(fragment)
|
||||
})
|
||||
|
||||
it('rejects a non-agent caller (the list has no owning session)', async () => {
|
||||
const ctx = await setup()
|
||||
const ctx = await setup(true)
|
||||
const result = await callTodo(ctx, { todos: [{ content: 'a', status: 'pending' }] }, { agent: undefined })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('owning agent session')
|
||||
})
|
||||
|
||||
it('presents the call with a stable title and the list as raw input', async () => {
|
||||
const ctx = await setup()
|
||||
const ctx = await setup(true)
|
||||
const def = ctx.tools.get('todo_write')!
|
||||
const todos = [{ content: 'a', status: 'pending' }]
|
||||
expect(def.presentCall?.({ todos })).toEqual({ card: 'generic', title: 'Update todo list', kind: 'other', rawInput: todos })
|
||||
@@ -225,7 +213,7 @@ describe('dsh-tool-todo', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
const fiber = await ctx.plugin(tool)
|
||||
const fiber = await ctx.plugin(tool, { allowParallelInProgress: true })
|
||||
expect(ctx.tools.schemas().some(s => s.name === 'todo_write')).toBe(true)
|
||||
await fiber.dispose()
|
||||
expect(ctx.tools.schemas().some(s => s.name === 'todo_write')).toBe(false)
|
||||
|
||||
Reference in New Issue
Block a user