index.ts 19 KB

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