1
/**2
* Model-facing whole-list replacement. Each call appends a `todo/write` snapshot to the calling3
* agent's session; replay is last-write-wins, and UIs render from session events. A non-agent4
* caller has no owning list and is rejected. Named exports preserve loader injection metadata.5
* @module @deepseek-ai/dsh-tool-todo6
*/8
import type { Context } from '@deepseek-ai/cordis'9
import z from '@deepseek-ai/schemastery'10
import { z as zod } from 'zod'11
import type { ZodType } from 'zod'12
import { defineTool } from '@deepseek-ai/dsh-tools'13
import type { TodoItem } from './types.ts'14
// Type-only: resolves the required ctx.sessionProjections service declaration.15
import type {} from '@deepseek-ai/dsh-session-projection'16
// The `todos` projection-key declaration lives in src/types.ts (its one home);17
// this re-export projects the type face onto the package root AND keeps the18
// module edge in the emitted index.d.ts, so aggregate programs consuming the19
// declarations still receive the SessionProjectionMap merge.20
export type * from './types.ts'22
export const name = 'tool-todo'23
export const inject = ['tools', 'sessionProjections']25
/** The valid {@link TodoItem} statuses, as a runtime set for input narrowing. */26
const STATUSES = ['pending', 'in_progress', 'completed'] as const28
/** Model-facing todo tool configuration. */29
export interface Config {30
/**31
* Required deployment choice for whether several todos may be `in_progress` at once. True suits32
* agents that run work concurrently — subagents, background commands, workflow fan-out — and the33
* description then instructs the model to mark every actively worked task. False restores the34
* single-active discipline: the description asks for exactly one, and a call marking more is35
* rejected.36
*/37
allowParallelInProgress: boolean38
}40
/** Schemastery configuration for the todo tool consumer. */41
export const Config: z<Config> = z.object({42
allowParallelInProgress: z.boolean().required(),43
})45
const DESCRIPTION_HEAD =46
'Record and update a task list to plan multi-step work and show progress; skip it for trivial '47
+ 'single-step tasks. Add one todo per concrete step before you start. '49
const DESCRIPTION_PARALLEL =50
'While work remains, keep the todos being worked on `in_progress`, several only when work runs in parallel. '52
const DESCRIPTION_SINGLE =53
'While work remains, keep exactly one todo `in_progress`. '55
const DESCRIPTION_TAIL = 'Mark each todo `completed` as soon as it is done.'57
/**58
* The model-facing description for one activation. The active-status clause is the only part that59
* varies, because it is the only instruction the parallel policy changes.60
* @param allowParallel - whether several todos may be `in_progress` at once.61
* @returns the composed tool description.62
*/63
function describe(allowParallel: boolean): string {64
return DESCRIPTION_HEAD65
+ (allowParallel ? DESCRIPTION_PARALLEL : DESCRIPTION_SINGLE)66
+ DESCRIPTION_TAIL67
}69
/**70
* Validate the value constraints the ParameterSchemaSpec can't express and build the canonical {@link71
* TodoItem}[]: trimmed non-empty unique content, and at most one `in_progress` item unless the72
* deployment allows parallel work. The registry has already enforced the status enum and rejected73
* unknown item keys (`additionalProperties: false` — the logged snapshot must equal what the model74
* believes it wrote, so a nested/extended item shape fails loud at the schema boundary instead of75
* silently flattening); the cast below records that guarantee.76
* @param raw - the model-supplied list, already schema-checked.77
* @param allowParallel - whether several items may be `in_progress` at once.78
* @returns the canonical list.79
*/80
function toTodoList(raw: { content: string; status: string }[], allowParallel: boolean): TodoItem[] {81
const todos: TodoItem[] = []82
const seen = new Set<string>()83
let active = 084
for (const item of raw) {85
const content = item.content.trim()86
if (content.length === 0) {87
throw new Error('invalid todo: `content` must be a non-empty string')88
}89
if (seen.has(content)) {90
throw new Error(`invalid todos: duplicate content ${JSON.stringify(content)}`)91
}92
seen.add(content)93
if (item.status === 'in_progress') active++94
todos.push({ content, status: item.status as TodoItem['status'] })95
}96
if (!allowParallel && active > 1) {97
throw new Error(`invalid todos: at most one task may be in_progress (got ${active})`)98
}99
return todos100
}102
/** Wire payload schema of the `todos` projection (whole list or pre-first-write null). */103
const todosProjectionSchema: ZodType<TodoItem[] | null> = zod.union([104
zod.array(zod.object({105
content: zod.string(),106
status: zod.union([zod.literal('pending'), zod.literal('in_progress'), zod.literal('completed')]),107
})),108
zod.null(),109
])111
/**112
* Register the `todo_write` tool on `ctx.tools` and the `todos` unit on113
* `ctx.sessionProjections`.114
* @param ctx - registrant context carrying the tool and session-projection registries.115
* @param config - deployment's explicit todo policy.116
*/117
export function apply(ctx: Context, config: Config): void {118
const allowParallel = config.allowParallelInProgress119
// Standing-plan fold: latest whole todo/write list, cleared by the next120
// turn/start (turn/end keeps the finished checklist visible); null before the121
// first write or after a later turn begins; every other event returns the122
// same state reference.123
ctx.sessionProjections.register<'todos', TodoItem[] | null>({124
key: 'todos',125
stateSchema: todosProjectionSchema,126
init: () => null,127
apply: (state, event) => {128
if (event.type === 'todo/write') return event.data.todos129
if (event.type === 'turn/start') return null130
return state131
},132
wire: { viewSchema: todosProjectionSchema, view: state => state },133
stateVersion: 2,134
})135
ctx.tools.register(defineTool({136
name: 'todo_write',137
description: describe(allowParallel),138
parameters: {139
todos: {140
type: 'array',141
required: true,142
description: 'The COMPLETE task list, replacing any previous list.',143
items: {144
type: 'object',145
additionalProperties: false,146
properties: {147
content: { type: 'string', required: true, description: 'What the task is — a short imperative line.' },148
status: {149
type: 'string',150
required: true,151
enum: [...STATUSES],152
description: 'pending (not started) | in_progress (now) | completed (done).',153
},154
},155
},156
},157
},158
output: {159
schema: {160
type: 'object',161
additionalProperties: false,162
properties: {163
todos: {164
type: 'array',165
required: true,166
items: {167
type: 'object',168
additionalProperties: false,169
properties: {170
content: { type: 'string', required: true },171
status: { type: 'string', required: true, enum: [...STATUSES] },172
},173
},174
},175
counts: {176
type: 'object',177
additionalProperties: false,178
required: true,179
properties: {180
pending: { type: 'integer', required: true },181
inProgress: { type: 'integer', required: true },182
completed: { type: 'integer', required: true },183
},184
},185
},186
},187
render: (_args, value) => [{188
type: 'text',189
text: `Updated todo list: ${value.counts.pending} pending, ${value.counts.inProgress} in progress, ${value.counts.completed} completed.`,190
}],191
},192
execute(args, exec) {193
const todos = toTodoList(args.todos, allowParallel)194
if (!exec.agent) {195
// The list is per-agent-session state; a non-agent caller (no owning196
// session) has nowhere to write it. Reject rather than silently no-op.197
throw new Error('todo_write requires an owning agent session')198
}199
exec.agent.session.append('todo/write', { todos })200
const count = (status: TodoItem['status']): number => todos.filter(t => t.status === status).length201
return Promise.resolve({202
todos: todos.map(todo => ({ content: todo.content, status: todo.status })),203
counts: {204
pending: count('pending'),205
inProgress: count('in_progress'),206
completed: count('completed'),207
},208
})209
},210
presentCall: args => ({ card: 'generic', title: 'Update todo list', kind: 'other', rawInput: args.todos }),211
}))212
}