index.ts 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148
  1. /**
  2. * Model-facing `task_output`, `task_list`, and `task_kill` tools over
  3. * `ctx.tasks`. Loading the plugin attaches the control surface required by
  4. * producers. It also injects unreported completions as durable context for the
  5. * owner's next request; notices do not wake idle agents.
  6. * @module @deepseek-ai/dsh-tool-tasks
  7. */
  8. import type { Context } from 'cordis'
  9. import z from 'schemastery'
  10. import { defineTool } from '@deepseek-ai/dsh-tools'
  11. import type { GenericCallView } from '@deepseek-ai/dsh-tools'
  12. import { TaskId } from '@deepseek-ai/dsh-tasks'
  13. import type { TaskSnapshot } from '@deepseek-ai/dsh-tasks'
  14. import type {} from '@deepseek-ai/dsh-system-prompt'
  15. export const name = 'tool-tasks'
  16. export const inject = ['tools', 'tasks', 'systemPrompt']
  17. /** Configures bounded `task_output` waits. */
  18. export interface Config {
  19. /** Wait duration applied when `task_output` sets `wait` without `timeout_ms` (default 30s). */
  20. waitTimeoutMs?: number
  21. /** Hard cap on any single wait; a larger model-supplied `timeout_ms` is clamped down to it (default 10min). */
  22. maxWaitTimeoutMs?: number
  23. }
  24. export const Config: z<Config> = z.object({
  25. waitTimeoutMs: z.number().min(1).default(30_000),
  26. maxWaitTimeoutMs: z.number().min(1).default(600_000),
  27. })
  28. /**
  29. * Render generic status with optional producer detail.
  30. * @param snapshot - task state to render.
  31. * @returns a bracketed status line.
  32. */
  33. export function statusLine(snapshot: TaskSnapshot): string {
  34. return snapshot.detail !== undefined
  35. ? `[status: ${snapshot.status}, ${snapshot.detail}]`
  36. : `[status: ${snapshot.status}]`
  37. }
  38. /** Validate the non-empty constraint that SchemaSpec cannot express. */
  39. function validateTaskId(value: string): TaskId {
  40. if (value.length === 0) {
  41. throw new Error(`invalid task_id: expected a non-empty string, got ${JSON.stringify(value)}`)
  42. }
  43. return TaskId(value)
  44. }
  45. /** Pending presentation shared by the three generic task controls. */
  46. function presentTaskCall(title: string, kind: 'read' | 'execute', rawInput?: string): GenericCallView {
  47. return { card: 'generic', title, kind, ...rawInput !== undefined ? { rawInput } : {} }
  48. }
  49. export function apply(ctx: Context, config: Config): void {
  50. const waitDefault = config.waitTimeoutMs ?? 30_000
  51. const waitCap = config.maxWaitTimeoutMs ?? 600_000
  52. if (waitDefault > waitCap) {
  53. throw new Error(`tool-tasks: waitTimeoutMs (${waitDefault}) exceeds maxWaitTimeoutMs (${waitCap})`)
  54. }
  55. // Producers may start work only while a control surface is attached.
  56. ctx.tasks.attachSurface('tool-tasks')
  57. // Cross-call guidance follows the bash section and precedes product sections.
  58. ctx.systemPrompt.section({
  59. name: 'tool:tasks',
  60. order: 106,
  61. text: 'Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task\'s work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.',
  62. })
  63. // Use the exact lifecycle owner; reusable ids could resolve to a replacement.
  64. ctx.tasks.onTaskDone((snapshot, owner) => {
  65. if (snapshot.reported || owner === undefined) return
  66. try {
  67. owner.inject(
  68. [{ type: 'text', text: `background task ${snapshot.id} (${snapshot.kind}: ${snapshot.label}) finished ${statusLine(snapshot)}. Read its output with task_output.` }],
  69. { source: { kind: 'plugin', plugin: 'tool-tasks' } },
  70. )
  71. } catch (error: unknown) {
  72. // Disposal may win the race after settlement; other injection failures surface.
  73. if (error instanceof Error && error.message.includes('is disposed')) return
  74. throw error
  75. }
  76. })
  77. ctx.tools.register(defineTool({
  78. name: 'task_output',
  79. description: 'Read a background task. Stream tasks return only output since the previous read; '
  80. + 'final-output tasks return their result after settlement. Every response ends with '
  81. + '`[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.',
  82. // A timed-out wait returns task state rather than a TOOL_TIMEOUT error, so
  83. // this tool owns its deadline instead of using ToolDefinition.timeoutMs.
  84. parameters: {
  85. task_id: { type: 'string', required: true, description: 'Task id returned by the tool that started the background work.' },
  86. wait: { type: 'boolean', description: 'Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive.' },
  87. timeout_ms: { type: 'number', description: 'Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum.' },
  88. },
  89. async execute(args, exec) {
  90. const id = validateTaskId(args.task_id)
  91. if (args.wait === true) {
  92. const timeout = Math.min(args.timeout_ms ?? waitDefault, waitCap)
  93. await ctx.tasks.wait(id, timeout, exec.agent, exec.signal)
  94. }
  95. const read = ctx.tasks.read(id, exec.agent)
  96. const body = read.text.length > 0 ? read.text : '(no new output)'
  97. const separator = body.endsWith('\n') ? '' : '\n'
  98. return [{ type: 'text', text: `${body}${separator}${statusLine(read.snapshot)}` }]
  99. },
  100. presentCall: args => presentTaskCall(`Read output from background task ${args.task_id}`, 'read', args.task_id),
  101. }))
  102. ctx.tools.register(defineTool({
  103. name: 'task_list',
  104. description: 'List your background tasks (running and finished) with their ids, kinds, and statuses.',
  105. parameters: {},
  106. execute(_args, exec) {
  107. const tasks = ctx.tasks.list(exec.agent)
  108. const text = tasks.length === 0
  109. ? '(no background tasks)'
  110. : tasks.map(t => `${t.id} [${t.kind}] ${t.status} — ${t.label}`).join('\n')
  111. return Promise.resolve([{ type: 'text', text }])
  112. },
  113. presentCall: () => presentTaskCall('List background tasks', 'read'),
  114. }))
  115. ctx.tools.register(defineTool({
  116. name: 'task_kill',
  117. description: 'Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.',
  118. parameters: {
  119. task_id: { type: 'string', required: true, description: 'Task id returned by the tool that started the background work.' },
  120. reason: { type: 'string', description: 'Optional short reason, recorded in the log and forwarded to the task.' },
  121. },
  122. execute(args, exec) {
  123. const id = validateTaskId(args.task_id)
  124. const result = ctx.tasks.kill(id, exec.agent, args.reason)
  125. if (result === 'already-finished') {
  126. // A snapshot describes terminal state without consuming pending output.
  127. const snapshot = ctx.tasks.get(id, exec.agent)
  128. return Promise.resolve([{ type: 'text', text: `task ${id} had already finished ${statusLine(snapshot)}` }])
  129. }
  130. return Promise.resolve([{ type: 'text', text: `requested cancellation of task ${id}` }])
  131. },
  132. presentCall: args => presentTaskCall(`Kill background task ${args.task_id}`, 'execute', args.task_id),
  133. }))
  134. }