231 lines
9.4 KiB
TypeScript
231 lines
9.4 KiB
TypeScript
/**
|
|
* Model-facing `task_output`, `task_list`, and `task_kill` tools over
|
|
* `ctx.tasks`. Loading the plugin attaches the control surface required by
|
|
* producers. It also injects unreported completions as durable context for the
|
|
* owner's next request; notices do not wake idle agents.
|
|
* @module @deepseek-ai/dsh-tool-tasks
|
|
*/
|
|
|
|
import type { Context } from 'cordis'
|
|
import z from 'schemastery'
|
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
import type { GenericCallView } from '@deepseek-ai/dsh-tools'
|
|
import { TaskId } from '@deepseek-ai/dsh-tasks'
|
|
import type { TaskSnapshot } from '@deepseek-ai/dsh-tasks'
|
|
import type {} from '@deepseek-ai/dsh-system-prompt'
|
|
|
|
export const name = 'tool-tasks'
|
|
export const inject = ['tools', 'tasks', 'systemPrompt']
|
|
|
|
/** Configures bounded `task_output` waits. */
|
|
export interface Config {
|
|
/** Wait duration applied when `task_output` sets `wait` without `timeout_ms` (default 30s). */
|
|
waitTimeoutMs?: number
|
|
/** Hard cap on any single wait; a larger model-supplied `timeout_ms` is clamped down to it (default 10min). */
|
|
maxWaitTimeoutMs?: number
|
|
}
|
|
|
|
export const Config: z<Config> = z.object({
|
|
waitTimeoutMs: z.number().min(1).default(30_000),
|
|
maxWaitTimeoutMs: z.number().min(1).default(600_000),
|
|
})
|
|
|
|
/** Task state safe for model-authored programs; ownership/bookkeeping fields are omitted. */
|
|
export interface PublicTaskSnapshot {
|
|
id: string
|
|
kind: string
|
|
label: string
|
|
status: TaskSnapshot['status']
|
|
detail?: string
|
|
startedAt: number
|
|
finishedAt?: number
|
|
}
|
|
|
|
/** Shared schema for task-control outputs. */
|
|
const PUBLIC_TASK_SCHEMA = {
|
|
type: 'object',
|
|
additionalProperties: false,
|
|
properties: {
|
|
id: { type: 'string', required: true },
|
|
kind: { type: 'string', required: true },
|
|
label: { type: 'string', required: true },
|
|
status: {
|
|
type: 'string',
|
|
required: true,
|
|
enum: ['running', 'stopping', 'completed', 'killed', 'failed'],
|
|
},
|
|
detail: { type: 'string' },
|
|
startedAt: { type: 'integer', required: true },
|
|
finishedAt: { type: 'integer' },
|
|
},
|
|
} as const
|
|
|
|
/** Remove task ownership and notification bookkeeping from a registry snapshot. */
|
|
function publicTask(snapshot: TaskSnapshot): PublicTaskSnapshot {
|
|
return {
|
|
id: snapshot.id,
|
|
kind: snapshot.kind,
|
|
label: snapshot.label,
|
|
status: snapshot.status,
|
|
...snapshot.detail !== undefined ? { detail: snapshot.detail } : {},
|
|
startedAt: snapshot.startedAt,
|
|
...snapshot.finishedAt !== undefined ? { finishedAt: snapshot.finishedAt } : {},
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Render generic status with optional producer detail.
|
|
* @param snapshot - task state to render.
|
|
* @returns a bracketed status line.
|
|
*/
|
|
export function statusLine(snapshot: Pick<TaskSnapshot, 'status' | 'detail'>): string {
|
|
return snapshot.detail !== undefined
|
|
? `[status: ${snapshot.status}, ${snapshot.detail}]`
|
|
: `[status: ${snapshot.status}]`
|
|
}
|
|
|
|
/** Validate the non-empty constraint that ParameterSchemaSpec cannot express. */
|
|
function validateTaskId(value: string): TaskId {
|
|
if (value.length === 0) {
|
|
throw new Error(`invalid task_id: expected a non-empty string, got ${JSON.stringify(value)}`)
|
|
}
|
|
return TaskId(value)
|
|
}
|
|
|
|
/** Pending presentation shared by the three generic task controls. */
|
|
function presentTaskCall(title: string, kind: 'read' | 'execute', rawInput?: string): GenericCallView {
|
|
return { card: 'generic', title, kind, ...rawInput !== undefined ? { rawInput } : {} }
|
|
}
|
|
|
|
export function apply(ctx: Context, config: Config): void {
|
|
const waitDefault = config.waitTimeoutMs ?? 30_000
|
|
const waitCap = config.maxWaitTimeoutMs ?? 600_000
|
|
if (waitDefault > waitCap) {
|
|
throw new Error(`tool-tasks: waitTimeoutMs (${waitDefault}) exceeds maxWaitTimeoutMs (${waitCap})`)
|
|
}
|
|
|
|
// Producers may start work only while a control surface is attached.
|
|
ctx.tasks.attachSurface('tool-tasks')
|
|
|
|
// Cross-call guidance follows the bash section and precedes product sections.
|
|
ctx.systemPrompt.section({
|
|
name: 'tool:tasks',
|
|
order: 106,
|
|
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.',
|
|
})
|
|
|
|
// Use the exact lifecycle owner; reusable ids could resolve to a replacement.
|
|
ctx.tasks.onTaskDone((snapshot, owner) => {
|
|
if (snapshot.reported || owner === undefined) return
|
|
try {
|
|
owner.inject(
|
|
[{ type: 'text', text: `background task ${snapshot.id} (${snapshot.kind}: ${snapshot.label}) finished ${statusLine(snapshot)}. Read its output with task_output.` }],
|
|
{ source: { kind: 'plugin', plugin: 'tool-tasks' } },
|
|
)
|
|
} catch (error: unknown) {
|
|
// Disposal may win the race after settlement; other injection failures surface.
|
|
if (error instanceof Error && error.message.includes('is disposed')) return
|
|
throw error
|
|
}
|
|
})
|
|
|
|
ctx.tools.register(defineTool({
|
|
name: 'task_output',
|
|
description: 'Read a background task. Stream tasks return only output since the previous read; '
|
|
+ 'final-output tasks return their result after settlement. Every response ends with '
|
|
+ '`[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.',
|
|
// A timed-out wait returns task state rather than a TOOL_TIMEOUT error, so
|
|
// this tool owns its deadline instead of using ToolDefinition.timeoutMs.
|
|
parameters: {
|
|
task_id: { type: 'string', required: true, description: 'Task id returned by the tool that started the background work.' },
|
|
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.' },
|
|
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.' },
|
|
},
|
|
output: {
|
|
schema: {
|
|
type: 'object',
|
|
additionalProperties: false,
|
|
properties: {
|
|
text: { type: 'string', required: true },
|
|
task: { ...PUBLIC_TASK_SCHEMA, required: true },
|
|
},
|
|
},
|
|
render: (_args, value) => {
|
|
const body = value.text.length > 0 ? value.text : '(no new output)'
|
|
const separator = body.endsWith('\n') ? '' : '\n'
|
|
return [{ type: 'text', text: `${body}${separator}${statusLine(value.task)}` }]
|
|
},
|
|
},
|
|
async execute(args, exec) {
|
|
const id = validateTaskId(args.task_id)
|
|
if (args.wait === true) {
|
|
const timeout = Math.min(args.timeout_ms ?? waitDefault, waitCap)
|
|
await ctx.tasks.wait(id, timeout, exec.agent, exec.signal)
|
|
}
|
|
const read = ctx.tasks.read(id, exec.agent)
|
|
return { text: read.text, task: publicTask(read.snapshot) }
|
|
},
|
|
presentCall: args => presentTaskCall(`Read output from background task ${args.task_id}`, 'read', args.task_id),
|
|
}))
|
|
|
|
ctx.tools.register(defineTool({
|
|
name: 'task_list',
|
|
description: 'List your background tasks (running and finished) with their ids, kinds, and statuses.',
|
|
parameters: {},
|
|
output: {
|
|
schema: { type: 'array', items: PUBLIC_TASK_SCHEMA },
|
|
render: (_args, tasks) => [{
|
|
type: 'text',
|
|
text: tasks.length === 0
|
|
? '(no background tasks)'
|
|
: tasks.map(t => `${t.id} [${t.kind}] ${t.status} — ${t.label}`).join('\n'),
|
|
}],
|
|
},
|
|
execute(_args, exec) {
|
|
const tasks = ctx.tasks.list(exec.agent)
|
|
return Promise.resolve(tasks.map(publicTask))
|
|
},
|
|
presentCall: () => presentTaskCall('List background tasks', 'read'),
|
|
}))
|
|
|
|
ctx.tools.register(defineTool({
|
|
name: 'task_kill',
|
|
description: 'Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.',
|
|
parameters: {
|
|
task_id: { type: 'string', required: true, description: 'Task id returned by the tool that started the background work.' },
|
|
reason: { type: 'string', description: 'Optional short reason, recorded in the log and forwarded to the task.' },
|
|
},
|
|
output: {
|
|
schema: {
|
|
type: 'object',
|
|
additionalProperties: false,
|
|
properties: {
|
|
outcome: {
|
|
type: 'string',
|
|
required: true,
|
|
enum: ['cancellation-requested', 'already-finished'],
|
|
},
|
|
task: { ...PUBLIC_TASK_SCHEMA, required: true },
|
|
},
|
|
},
|
|
render: (_args, value) => [{
|
|
type: 'text',
|
|
text: value.outcome === 'already-finished'
|
|
? `task ${value.task.id} had already finished ${statusLine(value.task)}`
|
|
: `requested cancellation of task ${value.task.id}`,
|
|
}],
|
|
},
|
|
execute(args, exec) {
|
|
const id = validateTaskId(args.task_id)
|
|
const result = ctx.tasks.kill(id, exec.agent, args.reason)
|
|
// A snapshot describes current state without consuming pending output.
|
|
const snapshot = publicTask(ctx.tasks.get(id, exec.agent))
|
|
return Promise.resolve({
|
|
outcome: result === 'already-finished' ? 'already-finished' as const : 'cancellation-requested' as const,
|
|
task: snapshot,
|
|
})
|
|
},
|
|
presentCall: args => presentTaskCall(`Kill background task ${args.task_id}`, 'execute', args.task_id),
|
|
}))
|
|
}
|