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:
Chinesezjc
2026-07-29 14:49:45 +08:00
parent 39d65df019
commit 3a61c4c568
13 changed files with 339 additions and 45 deletions

View File

@@ -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.

View File

@@ -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>()