list-agents.ts 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193
  1. /**
  2. * The globally named `list_agents` tool: a thin model-facing adapter over
  3. * the continuable projection of `ctx.subagents.listChildren()` and, for the
  4. * `descendants` scope, `ctx.subagents.listDescendants()`. It stays separately
  5. * loadable from the root `send_message` plugin so a deployment can register
  6. * continuation delivery without exposing discovery.
  7. * @module @deepseek-ai/dsh-tool-subagent-control/list-agents
  8. */
  9. import type { Context } from '@deepseek-ai/cordis'
  10. import { defineTool } from '@deepseek-ai/dsh-tools'
  11. import type { Agent } from '@deepseek-ai/dsh-agent'
  12. import type { SessionId } from '@deepseek-ai/dsh-session'
  13. import type { SubagentDescendantListEntry, SubagentListEntry } from '@deepseek-ai/dsh-subagent'
  14. import { assertNever } from '@deepseek-ai/dsh-util-values'
  15. export const name = 'tool-subagent-list-agents'
  16. export const inject = ['tools', 'subagents', 'agents']
  17. type ListAgentsScope = 'children' | 'descendants'
  18. interface ListAgentsRequest {
  19. readonly scope?: ListAgentsScope
  20. }
  21. interface ListAgentsSpec {
  22. readonly scope: ListAgentsScope
  23. }
  24. type ListAgentsEntry =
  25. | {
  26. readonly kind: 'child'
  27. readonly id: SessionId
  28. readonly label: string
  29. readonly status: 'running' | 'idle' | 'ready'
  30. readonly parent?: SessionId
  31. readonly depth?: number
  32. }
  33. | {
  34. readonly kind: 'diagnostic'
  35. readonly id: SessionId
  36. readonly reason: 'corrupt' | 'unsupported' | 'unavailable'
  37. readonly parent?: SessionId
  38. readonly depth?: number
  39. }
  40. /** Resolve the optional model request into an internal required-scope spec. */
  41. function resolveListAgentsRequest(request: ListAgentsRequest): ListAgentsSpec {
  42. return { scope: request.scope ?? 'children' }
  43. }
  44. /**
  45. * Refine one candidate's status through the live Agent registry: `running`
  46. * for an active driver, `idle` for a resident Agent between turns (possibly
  47. * waiting on agents it started), and `ready` when no live Agent remains.
  48. * `ready` preserves resumability without presenting an inactive conversation
  49. * as a terminal result to collect.
  50. */
  51. function statusOf(agents: { get(id: SessionId): Agent | undefined }, id: SessionId): 'running' | 'idle' | 'ready' {
  52. const agent = agents.get(id)
  53. if (agent === undefined) return 'ready'
  54. return agent.status === 'running' ? 'running' : 'idle'
  55. }
  56. /** Project one service row into the model-facing entry, or omit a one-shot child. */
  57. function project(
  58. agents: { get(id: SessionId): Agent | undefined },
  59. entry: SubagentListEntry,
  60. position?: Pick<SubagentDescendantListEntry, 'parentId' | 'depth'>,
  61. ): ListAgentsEntry | undefined {
  62. const at = position === undefined ? {} : { parent: position.parentId, depth: position.depth }
  63. if (entry.kind === 'diagnostic') {
  64. return { kind: 'diagnostic', id: entry.id, reason: entry.reason, ...at }
  65. }
  66. // One-shot children cannot be continued by send_message, so the model
  67. // never selects them; discovery still traversed them for descendants.
  68. if (entry.mode !== 'continuable') return undefined
  69. return {
  70. kind: 'child',
  71. id: entry.id,
  72. label: entry.label,
  73. status: statusOf(agents, entry.id),
  74. ...at,
  75. }
  76. }
  77. /**
  78. * Register the `list_agents` tool.
  79. * @param ctx - context carrying the tool registry, subagent service, and live Agent registry.
  80. */
  81. export function apply(ctx: Context): void {
  82. ctx.tools.register(defineTool({
  83. name: 'list_agents',
  84. description:
  85. 'List your continuable background subagents by durable id and label. Use it to recall which ones '
  86. + 'you started, not to poll for completion — you are told when one finishes. Status comes from the live '
  87. + 'registry: running means the agent is working right now, idle means it is loaded but between turns '
  88. + '(it may be waiting on agents it started), and ready means it exists only in storage — resumable, not '
  89. + 'terminal, and not a result waiting to be collected; a `send_message` steers a running child at its nearest '
  90. + 'step boundary or starts a turn for an idle or ready child, and a direct child remains a `send_message` '
  91. + 'candidate in every status. The snapshot is not a delivery '
  92. + 'promise — `send_message` performs the authoritative check and may still fail. Children that could '
  93. + 'not be read are reported as diagnostics instead of being silently dropped. Scope `descendants` '
  94. + 'walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent '
  95. + 'session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are '
  96. + 'candidates for `interrupt_agent` only.',
  97. parameters: {
  98. scope: {
  99. type: 'string',
  100. enum: ['children', 'descendants'],
  101. description: 'children (default) lists direct children only; descendants walks the complete tree below you.',
  102. },
  103. },
  104. output: {
  105. schema: {
  106. type: 'array',
  107. items: {
  108. oneOf: [
  109. {
  110. type: 'object',
  111. additionalProperties: false,
  112. properties: {
  113. kind: { type: 'string', required: true, enum: ['child'] },
  114. id: { type: 'string', required: true },
  115. label: { type: 'string', required: true },
  116. status: { type: 'string', required: true, enum: ['running', 'idle', 'ready'] },
  117. parent: { type: 'string' },
  118. depth: { type: 'number' },
  119. },
  120. },
  121. {
  122. type: 'object',
  123. additionalProperties: false,
  124. properties: {
  125. kind: { type: 'string', required: true, enum: ['diagnostic'] },
  126. id: { type: 'string', required: true },
  127. reason: { type: 'string', required: true, enum: ['corrupt', 'unsupported', 'unavailable'] },
  128. parent: { type: 'string' },
  129. depth: { type: 'number' },
  130. },
  131. },
  132. ],
  133. },
  134. },
  135. render: (args, entries) => {
  136. const request = resolveListAgentsRequest(args)
  137. return [{
  138. type: 'text',
  139. text: entries.length === 0
  140. ? '(no subagents)'
  141. : entries.map((entry) => {
  142. // A descendants row always carries its position; children rows
  143. // never render it. String() spans the schema-optional shape
  144. // without a dead fallback branch.
  145. const at = request.scope === 'descendants'
  146. ? ` parent=${String(entry.parent)} depth=${String(entry.depth)}`
  147. : ''
  148. return entry.kind === 'child'
  149. ? `${entry.id} [${entry.status}]${at} — ${entry.label}`
  150. : `${entry.id} [diagnostic: ${entry.reason}]${at}`
  151. }).join('\n'),
  152. }]
  153. },
  154. },
  155. async execute(args, exec) {
  156. const parent = exec.agent
  157. if (!parent) {
  158. // Non-agent callers have no session whose children could be listed.
  159. throw new Error('list_agents requires a calling agent (exec.agent was undefined)')
  160. }
  161. const request = resolveListAgentsRequest(args)
  162. // The registry drains started tool bodies, so the scan must observe the
  163. // call's signal rather than finish a slow catalog after cancellation.
  164. switch (request.scope) {
  165. case 'children': {
  166. const entries = await ctx.subagents.listChildren(parent.id, exec.signal)
  167. return entries
  168. .map(entry => project(ctx.agents, entry))
  169. .filter(entry => entry !== undefined)
  170. }
  171. case 'descendants': {
  172. const entries = await ctx.subagents.listDescendants(parent.id, exec.signal)
  173. return entries
  174. .map(entry => project(ctx.agents, entry, entry))
  175. .filter(entry => entry !== undefined)
  176. }
  177. /* v8 ignore next 2 -- the resolver normalizes the schema-validated closed scope before dispatch. */
  178. default:
  179. return assertNever(request.scope, 'list_agents scope')
  180. }
  181. },
  182. }))
  183. }