forked from deepseek-ai/deepseek-harness
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathindex.ts
More file actions
418 lines (388 loc) · 19.2 KB
/
Copy pathindex.ts
File metadata and controls
418 lines (388 loc) · 19.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
/** Scoped model-facing tools for the opt-in Agent Teams runtime. */
import type { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { TeamTaskId } from '@deepseek-ai/dsh-experimental-agent-team'
import type { TeamMemberView } from '@deepseek-ai/dsh-experimental-agent-team'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { InferValue, ValueSchemaSpec } from '@deepseek-ai/dsh-tools'
/** Cordis plugin name. */
export const name = 'tool-agent-team'
/** Services required by the Team tool plugin. */
export const inject = ['agents', 'agentTeams', 'tools', 'systemPrompt']
/** Tool routing configuration. */
export interface Config {
/** Continuable-subagent provider used for fresh teammates. */
readonly freshProvider?: string
/** Continuable-subagent provider used for completed-prefix fork teammates. */
readonly forkProvider?: string
}
/** Loader schema for the opt-in Team tool plugin. */
export const Config: z<Config> = z.object({
freshProvider: z.string().default('spawn'),
forkProvider: z.string().default('fork'),
})
/** Model-facing collaboration guidance shared by Lead and teammates. */
const POLICY = `Agent Teams is available in this session, but create teammates only when the user explicitly asks to use Agent Teams or teammates.
The Team Lead and all teammates share the same working directory and filesystem. Edits are immediately visible to every member. Split write work into disjoint scopes, record expected write scopes on shared tasks, and use task dependencies when work must be ordered. Write-scope overlap is advisory, not a lock.
Prefer read/edit/write for file changes. If a file operation returns FS_STALE_VERSION, read the current file, rebase your intended change onto the new content, and retry. Bash, formatters, code generators, and scripts are not fully protected by the filesystem version guard; coordinate them explicitly and have the Lead review the final diff and run tests.
Use send_message for quiet information that must not start an idle teammate. Use followup_task when the target should run another turn. A delivered peer item starts with its stable message id and sender name. A successful send is already durable even when its result says queued; do not resend it. Shared-task workflow is list, get, claim with the current revision, perform the work, then complete. Task readiness never starts an owner. Before wait_agent, use list_agents and make sure another required member is running or provisioning; use followup_task first when the required member is inactive. wait_agent observes only changes after that call starts, never wakes a member, and returns noProgress immediately when no other member can produce a change. Re-list after wakeup or timeout. The Lead must wait for required teammates before giving the final answer.`
const ACTIVE_WAIT_STATUSES: ReadonlySet<TeamMemberView['status']> = new Set(['running', 'provisioning'])
const NO_ACTIVE_PEER_MESSAGE = 'No other Team member is running or provisioning. wait_agent cannot make progress or wake inactive teammates. Re-list with list_agents and team_task_list, then use followup_task to wake each required inactive teammate before waiting again.'
/**
* One roster row, matching `TeamMemberView`. The Lead pseudo-row omits the
* teammate-only provisioning fields, so only identity, role, status, and
* diagnostics are required.
*/
const MEMBER_VIEW_SCHEMA = {
type: 'object',
additionalProperties: false,
properties: {
id: { type: 'string', required: true },
name: { type: 'string', required: true },
role: { type: 'string', required: true, enum: ['lead', 'teammate'] },
status: { type: 'string', required: true, enum: ['running', 'idle', 'inactive', 'provisioning', 'failed'] },
description: { type: 'string' },
provider: { type: 'string' },
context: { type: 'string', enum: ['fresh', 'fork'] },
model: { type: 'string' },
diagnostics: { type: 'array', required: true, items: { type: 'string' } },
},
} as const
/** One shared task, matching the public `TeamTaskView`. */
const TASK_VIEW_SCHEMA = {
type: 'object',
additionalProperties: false,
properties: {
id: { type: 'string', required: true },
revision: { type: 'integer', required: true },
subject: { type: 'string', required: true },
description: { type: 'string', required: true },
status: { type: 'string', required: true, enum: ['pending', 'in_progress', 'completed', 'deleted'] },
ownerName: { type: 'string' },
blockedBy: { type: 'array', required: true, items: { type: 'string' } },
writeScopes: { type: 'array', required: true, items: { type: 'string' } },
ready: { type: 'boolean', required: true },
writeScopeWarnings: { type: 'array', required: true, items: { type: 'string' } },
},
} as const
const SPAWN_VALUE_SCHEMA = {
type: 'object',
additionalProperties: false,
properties: {
member: { ...MEMBER_VIEW_SCHEMA, required: true },
},
} as const
const MEMBER_LIST_VALUE_SCHEMA = { type: 'array', items: MEMBER_VIEW_SCHEMA } as const
const SEND_VALUE_SCHEMA = {
type: 'object',
additionalProperties: false,
properties: {
messageId: { type: 'string', required: true },
status: { type: 'string', required: true, enum: ['accepted', 'queued'] },
},
} as const
/** `noProgress` is present only on the model-only shortcut that skips the wait. */
const WAIT_VALUE_SCHEMA = {
type: 'object',
additionalProperties: false,
properties: {
timedOut: { type: 'boolean', required: true },
noProgress: {
type: 'object',
additionalProperties: false,
properties: {
reason: { type: 'string', required: true, const: 'no-active-peer' },
message: { type: 'string', required: true },
},
},
},
} as const
const INTERRUPT_VALUE_SCHEMA = {
type: 'object',
additionalProperties: false,
properties: {
previousStatus: { type: 'string', required: true, enum: ['running', 'idle', 'inactive'] },
},
} as const
const TASK_LIST_VALUE_SCHEMA = {
type: 'object',
additionalProperties: false,
properties: {
tasks: { type: 'array', required: true, items: TASK_VIEW_SCHEMA },
nextCursor: { type: 'integer' },
},
} as const
/**
* Declare one canonical output schema with compact model-facing JSON. Every
* Team result is a fixed record, so the declared schema is what makes the
* compiler check `execute` against the value the model is promised.
* @param schema - canonical value schema for one tool.
* @returns the `output` declaration accepted by {@link defineTool}.
*/
function jsonOutput<const S extends ValueSchemaSpec>(schema: S): {
schema: S
render: (args: unknown, value: InferValue<S>) => [{ type: 'text'; text: string }]
} {
return {
schema,
render: (_args: unknown, value: InferValue<S>) => [{ type: 'text', text: JSON.stringify(value) }],
}
}
/** Recover the exact caller guaranteed by Agent-scoped tool discovery. */
function callingAgent(agent: Agent | undefined, toolName: string): Agent {
/* v8 ignore next 2 -- Team tools are registered only in an exact Agent scope, so discovery supplies this carrier. */
if (agent === undefined) throw new Error(`${toolName} requires a calling Agent`)
return agent
}
/** Register the complete Team tool set in one exact Agent scope. */
function install(agent: Agent, ctx: Context, config: Required<Config>): () => void {
const scoped = agent.ctx
const disposers: Array<() => unknown> = []
const register = (disposer: () => unknown): void => { disposers.push(disposer) }
try {
register(scoped.systemPrompt.section({
name: 'team:policy',
order: 60,
text: () => {
const membership = ctx.agentTeams.membership(agent)
return `${POLICY}\n\nYour Team role is ${membership.role}; your Team name is ${membership.name}; Team id is ${membership.id}.`
},
}))
register(scoped.tools.register(defineTool({
name: 'spawn_teammate',
description: 'Create one named, durable teammate. Only the Team Lead may call this tool.',
parameters: {
name: { type: 'string', required: true, description: 'Unique lower-kebab-case teammate name.' },
description: { type: 'string', required: true, description: 'Short description of the delegated responsibility.' },
prompt: { type: 'string', required: true, description: 'Complete initial task for the teammate.' },
context: {
type: 'string',
enum: ['fresh', 'fork'],
description: 'fresh starts without Lead history; fork inherits completed Lead turns. Defaults to fresh.',
},
},
output: jsonOutput(SPAWN_VALUE_SCHEMA),
async execute(args, exec) {
const agent = callingAgent(exec.agent, 'spawn_teammate')
const context = args.context ?? 'fresh'
return await ctx.agentTeams.spawnTeammate(agent, {
name: args.name,
description: args.description,
prompt: [{ type: 'text', text: args.prompt }],
context,
provider: context === 'fork' ? config.forkProvider : config.freshProvider,
signal: exec.signal,
})
},
})))
const messageTool = (toolName: 'send_message' | 'followup_task', delivery: 'quiet' | 'wakeup'): void => {
register(scoped.tools.register(defineTool({
name: toolName,
description: delivery === 'quiet'
? 'Send durable information to another Team member without starting an idle member.'
: 'Send a durable follow-up task to another Team member and start a turn when needed.',
parameters: {
target: { type: 'string', required: true, description: 'Team member name, or lead.' },
message: { type: 'string', required: true, description: 'Self-contained message for the target.' },
},
output: jsonOutput(SEND_VALUE_SCHEMA),
execute(args, exec) {
return ctx.agentTeams.sendMessage(callingAgent(exec.agent, toolName), {
target: args.target,
content: [{ type: 'text', text: args.message }],
delivery,
signal: exec.signal,
})
},
})))
}
messageTool('send_message', 'quiet')
messageTool('followup_task', 'wakeup')
register(scoped.tools.register(defineTool({
name: 'list_agents',
description: 'List the Lead and every durable teammate with current runtime status.',
parameters: {},
output: jsonOutput(MEMBER_LIST_VALUE_SCHEMA),
async execute(_args, exec) {
return Promise.resolve(ctx.agentTeams.listMembers(callingAgent(exec.agent, 'list_agents')))
},
})))
register(scoped.tools.register(defineTool({
name: 'wait_agent',
description: 'Wait for the next teammate status, mailbox, or shared-task change after this call starts. This never wakes inactive members and returns noProgress immediately when no other member is running or provisioning. Re-list after wakeup or timeout instead of polling.',
parameters: {
timeout_ms: {
type: 'integer',
description: 'Wait duration in milliseconds, from 10000 through 3600000. Defaults to 30000.',
},
},
output: jsonOutput(WAIT_VALUE_SCHEMA),
async execute(args, exec) {
const caller = callingAgent(exec.agent, 'wait_agent')
const timeoutMs = args.timeout_ms ?? 30_000
// Preserve TeamService's authoritative timeout validation before the
// model-only no-progress shortcut.
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 10_000 || timeoutMs > 3_600_000) {
return await ctx.agentTeams.waitForChange(caller, timeoutMs, exec.signal)
}
// The active-peer read and waiter registration must remain one synchronous
// span; awaiting between them can lose the only peer-status edge.
const hasActivePeer = ctx.agentTeams.listMembers(caller).some(member =>
member.id !== caller.id && ACTIVE_WAIT_STATUSES.has(member.status))
if (!hasActivePeer) {
return {
timedOut: false,
noProgress: {
reason: 'no-active-peer' as const,
message: NO_ACTIVE_PEER_MESSAGE,
},
}
}
return await ctx.agentTeams.waitForChange(caller, timeoutMs, exec.signal)
},
})))
register(scoped.tools.register(defineTool({
name: 'interrupt_agent',
description: 'Interrupt one teammate\'s current turn while preserving its pending inbox. Team Lead only.',
parameters: {
target: { type: 'string', required: true, description: 'Teammate name.' },
},
output: jsonOutput(INTERRUPT_VALUE_SCHEMA),
async execute(args, exec) {
return Promise.resolve(ctx.agentTeams.interrupt(
callingAgent(exec.agent, 'interrupt_agent'),
args.target,
))
},
})))
register(scoped.tools.register(defineTool({
name: 'team_task_create',
description: 'Create one unowned pending task on the shared Team task board.',
parameters: {
subject: { type: 'string', required: true, description: 'Concise task title.' },
description: { type: 'string', required: true, description: 'Complete task details and acceptance criteria.' },
blocked_by: { type: 'array', items: { type: 'string' }, description: 'Task ids that must complete first.' },
write_scopes: {
type: 'array',
items: { type: 'string' },
description: 'Advisory workspace-relative file or directory prefixes this task expects to modify.',
},
},
output: jsonOutput(TASK_VIEW_SCHEMA),
async execute(args, exec) {
return await ctx.agentTeams.createTask(callingAgent(exec.agent, 'team_task_create'), {
subject: args.subject,
description: args.description,
...args.blocked_by === undefined ? {} : { blockedBy: args.blocked_by.map(TeamTaskId) },
...args.write_scopes === undefined ? {} : { writeScopes: args.write_scopes },
})
},
})))
register(scoped.tools.register(defineTool({
name: 'team_task_list',
description: 'List shared tasks, including readiness, owner, revision, blockers, and write-scope warnings.',
parameters: {
status: {
type: 'string',
enum: ['pending', 'in_progress', 'completed'],
description: 'Optional exact status filter.',
},
owner: { type: 'string', description: 'Optional member-name filter; use unowned for tasks without an owner.' },
ready: { type: 'boolean', description: 'Optional readiness filter.' },
cursor: { type: 'integer', description: 'Zero-based result offset. Defaults to 0.' },
limit: { type: 'integer', description: 'Number of rows, 1 through 100. Defaults to 50.' },
},
output: jsonOutput(TASK_LIST_VALUE_SCHEMA),
execute(args, exec) {
const status = args.status
const filtered = ctx.agentTeams.listTasks(callingAgent(exec.agent, 'team_task_list')).filter(task =>
(status === undefined || task.status === status)
&& (args.owner === undefined || (args.owner === 'unowned' ? task.ownerName === undefined : task.ownerName === args.owner))
&& (args.ready === undefined || task.ready === args.ready))
const cursor = args.cursor ?? 0
const limit = args.limit ?? 50
if (!Number.isSafeInteger(cursor) || cursor < 0) throw new Error('cursor must be a non-negative safe integer')
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) throw new Error('limit must be an integer from 1 through 100')
return Promise.resolve({
tasks: filtered.slice(cursor, cursor + limit),
...(cursor + limit < filtered.length ? { nextCursor: cursor + limit } : {}),
})
},
})))
register(scoped.tools.register(defineTool({
name: 'team_task_get',
description: 'Read the complete latest value of one shared task before changing or executing it.',
parameters: {
task_id: { type: 'string', required: true, description: 'Shared task id.' },
},
output: jsonOutput(TASK_VIEW_SCHEMA),
async execute(args, exec) {
return Promise.resolve(ctx.agentTeams.getTask(
callingAgent(exec.agent, 'team_task_get'),
TeamTaskId(args.task_id),
))
},
})))
register(scoped.tools.register(defineTool({
name: 'team_task_update',
description: 'Compare-and-set a shared task action using the latest revision from team_task_get or team_task_list.',
parameters: {
task_id: { type: 'string', required: true, description: 'Shared task id.' },
expected_revision: { type: 'integer', required: true, description: 'Current task revision used as the CAS precondition.' },
action: {
type: 'string',
required: true,
enum: ['claim', 'release', 'edit', 'set_dependencies', 'complete', 'reopen', 'reassign', 'delete'],
description: 'Task transition to apply.',
},
subject: { type: 'string', description: 'Replacement title for edit.' },
description: { type: 'string', description: 'Replacement details for edit.' },
blocked_by: { type: 'array', items: { type: 'string' }, description: 'Complete blocker list for set_dependencies.' },
write_scopes: { type: 'array', items: { type: 'string' }, description: 'Replacement advisory write scopes for edit.' },
owner: { type: 'string', description: 'Member name for Lead-only reassign; omit to unassign.' },
},
output: jsonOutput(TASK_VIEW_SCHEMA),
async execute(args, exec) {
return await ctx.agentTeams.updateTask(callingAgent(exec.agent, 'team_task_update'), {
taskId: TeamTaskId(args.task_id),
expectedRevision: args.expected_revision,
action: args.action,
...args.subject === undefined ? {} : { subject: args.subject },
...args.description === undefined ? {} : { description: args.description },
...args.blocked_by === undefined ? {} : { blockedBy: args.blocked_by.map(TeamTaskId) },
...args.write_scopes === undefined ? {} : { writeScopes: args.write_scopes },
...args.owner === undefined ? {} : { owner: args.owner },
})
},
})))
} catch (error: unknown) {
for (const dispose of disposers.reverse()) void dispose()
throw error
}
return () => {
for (const dispose of disposers.reverse()) void dispose()
}
}
/** Install Team tools in every live or subsequently published Team member scope. */
export function apply(ctx: Context, config: Config = {}): void {
const resolved: Required<Config> = {
freshProvider: config.freshProvider ?? 'spawn',
forkProvider: config.forkProvider ?? 'fork',
}
const installed = new Map<Agent, () => void>()
const maybeInstall = (agent: Agent): void => {
if (installed.has(agent) || ctx.agentTeams.tryMembership(agent) === undefined) return
installed.set(agent, install(agent, ctx, resolved))
}
for (const agent of ctx.agents.list()) maybeInstall(agent)
ctx.on('agent/created', ({ agent }) => { maybeInstall(agent) })
ctx.on('agent/disposed', ({ agent }) => {
installed.get(agent)?.()
installed.delete(agent)
})
ctx.effect(() => () => {
for (const dispose of installed.values()) dispose()
installed.clear()
}, 'tool-team.scopedTools()')
}