返回源码地图

packages/todo/tool-todo/src/index.ts

main snapshot · da00f7f5358f · 正文引用章节 17;完整原文可核对,不声称全文件人工逐行审计

完整原文供逐行核对;页面收录不代表每行都经过人工语义审核。MIT 许可见 许可证。

1/**
2 * Model-facing whole-list replacement. Each call appends a `todo/write` snapshot to the calling
3 * agent's session; replay is last-write-wins, and UIs render from session events. A non-agent
4 * caller has no owning list and is rejected. Named exports preserve loader injection metadata.
5 * @module @deepseek-ai/dsh-tool-todo
6 */
7
8import type { Context } from '@deepseek-ai/cordis'
9import z from '@deepseek-ai/schemastery'
10import { z as zod } from 'zod'
11import type { ZodType } from 'zod'
12import { defineTool } from '@deepseek-ai/dsh-tools'
13import type { TodoItem } from './types.ts'
14// Type-only: resolves the required ctx.sessionProjections service declaration.
15import 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 the
18// module edge in the emitted index.d.ts, so aggregate programs consuming the
19// declarations still receive the SessionProjectionMap merge.
20export type * from './types.ts'
21
22export const name = 'tool-todo'
23export const inject = ['tools', 'sessionProjections']
24
25/** The valid {@link TodoItem} statuses, as a runtime set for input narrowing. */
26const STATUSES = ['pending', 'in_progress', 'completed'] as const
27
28/** Model-facing todo tool configuration. */
29export interface Config {
30 /**
31 * Required deployment choice for whether several todos may be `in_progress` at once. True suits
32 * agents that run work concurrently — subagents, background commands, workflow fan-out — and the
33 * description then instructs the model to mark every actively worked task. False restores the
34 * single-active discipline: the description asks for exactly one, and a call marking more is
35 * rejected.
36 */
37 allowParallelInProgress: boolean
38}
39
40/** Schemastery configuration for the todo tool consumer. */
41export const Config: z<Config> = z.object({
42 allowParallelInProgress: z.boolean().required(),
43})
44
45const 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. '
48
49const DESCRIPTION_PARALLEL =
50 'While work remains, keep the todos being worked on `in_progress`, several only when work runs in parallel. '
51
52const DESCRIPTION_SINGLE =
53 'While work remains, keep exactly one todo `in_progress`. '
54
55const DESCRIPTION_TAIL = 'Mark each todo `completed` as soon as it is done.'
56
57/**
58 * The model-facing description for one activation. The active-status clause is the only part that
59 * 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 */
63function describe(allowParallel: boolean): string {
64 return DESCRIPTION_HEAD
65 + (allowParallel ? DESCRIPTION_PARALLEL : DESCRIPTION_SINGLE)
66 + DESCRIPTION_TAIL
67}
68
69/**
70 * Validate the value constraints the ParameterSchemaSpec can't express and build the canonical {@link
71 * TodoItem}[]: trimmed non-empty unique content, and at most one `in_progress` item unless the
72 * deployment allows parallel work. The registry has already enforced the status enum and rejected
73 * unknown item keys (`additionalProperties: false` — the logged snapshot must equal what the model
74 * believes it wrote, so a nested/extended item shape fails loud at the schema boundary instead of
75 * 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 */
80function toTodoList(raw: { content: string; status: string }[], allowParallel: boolean): TodoItem[] {
81 const todos: TodoItem[] = []
82 const seen = new Set<string>()
83 let active = 0
84 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 todos
100}
101
102/** Wire payload schema of the `todos` projection (whole list or pre-first-write null). */
103const 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])
110
111/**
112 * Register the `todo_write` tool on `ctx.tools` and the `todos` unit on
113 * `ctx.sessionProjections`.
114 * @param ctx - registrant context carrying the tool and session-projection registries.
115 * @param config - deployment's explicit todo policy.
116 */
117export function apply(ctx: Context, config: Config): void {
118 const allowParallel = config.allowParallelInProgress
119 // Standing-plan fold: latest whole todo/write list, cleared by the next
120 // turn/start (turn/end keeps the finished checklist visible); null before the
121 // first write or after a later turn begins; every other event returns the
122 // 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.todos
129 if (event.type === 'turn/start') return null
130 return state
131 },
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 owning
196 // 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).length
201 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}