The TUI's referenced-prompt snapshot now rides the prompt's own admission transaction instead of a pre-admission inject: while idle, a one-shot prepended agent/prompt-submit wrapper appends the snapshot to the allow decision's additionalContexts, so a blocking hook discards the prompt and its attached context together instead of stranding the snapshot in history for the next unrelated prompt. A prompt discarded before admission releases the wrapper; steering keeps the inject path since it bypasses admission and drains at the same boundary. The session-reference snapshot adapter pinned the old context-before-prompt order; the branch-wide order (prompt first, its contexts after) is now asserted and the fixture re-recorded. tool-tasks drops the last consumer of the removed thrown-disposed contract: completion notices now inject unconditionally, which is well-defined during owner teardown — the loop treats disposal like any cancel, so the notice appends as durable idle context (persisted for resume while the session is attached, dropped with the detached log after). README pair and the owner-disposal tests state the new delivery contract.
338 lines
14 KiB
TypeScript
338 lines
14 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 type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
|
import { TextRetainer } from '@deepseek-ai/dsh-retention'
|
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
import type { GenericCallView, ToolDefinition, ToolExecution } 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}]`
|
|
}
|
|
|
|
const encoder = new TextEncoder()
|
|
|
|
function retainTail(text: string, maxBytes: number): string {
|
|
const retainer = new TextRetainer({ kind: 'tail', maxBytes })
|
|
retainer.push(text)
|
|
return retainer.finish().text
|
|
}
|
|
|
|
function retainHead(text: string, maxBytes: number): string {
|
|
const retainer = new TextRetainer({ kind: 'head', maxBytes })
|
|
retainer.push(text)
|
|
return retainer.finish().text
|
|
}
|
|
|
|
function fitWithSuffix(
|
|
content: string,
|
|
suffix: string,
|
|
maxBytes: number | undefined,
|
|
omitted: string,
|
|
): string {
|
|
const complete = `${content}${suffix}`
|
|
if (maxBytes === undefined || encoder.encode(complete).byteLength <= maxBytes) return complete
|
|
const fixed = `${content.endsWith(omitted.trimStart()) ? '' : omitted}${suffix}`
|
|
const fixedBytes = encoder.encode(fixed).byteLength
|
|
if (fixedBytes >= maxBytes) return retainTail(fixed, maxBytes)
|
|
return `${retainTail(content, maxBytes - fixedBytes)}${fixed}`
|
|
}
|
|
|
|
function fitCompletionNotice(snapshot: TaskSnapshot): string {
|
|
const prefix = `background task ${snapshot.id}`
|
|
const detail = ` (${snapshot.kind}: ${snapshot.label}) finished ${statusLine(snapshot)}`
|
|
const action = '\nDone; task_output.'
|
|
const complete = `${prefix}${detail}. Read its output with task_output.`
|
|
const maxBytes = snapshot.outputLimitBytes
|
|
if (maxBytes === undefined || encoder.encode(complete).byteLength <= maxBytes) return complete
|
|
const omitted = '\n[notice truncated]'
|
|
const fixed = `${prefix}${omitted}${action}`
|
|
const fixedBytes = encoder.encode(fixed).byteLength
|
|
if (fixedBytes <= maxBytes) {
|
|
return fixedBytes === maxBytes
|
|
? fixed
|
|
: `${prefix}${retainHead(detail, maxBytes - fixedBytes)}${omitted}${action}`
|
|
}
|
|
const compact = `${prefix}${action}`
|
|
const compactBytes = encoder.encode(compact).byteLength
|
|
if (compactBytes <= maxBytes) return compact
|
|
const actionBytes = encoder.encode(action).byteLength
|
|
if (actionBytes >= maxBytes) return retainTail(action, maxBytes)
|
|
return `${retainHead(prefix, maxBytes - actionBytes)}${action}`
|
|
}
|
|
|
|
function rawSingleText(content: readonly ContentBlock[]): string | undefined {
|
|
if (content.length !== 1) return undefined
|
|
const block = content[0]
|
|
if (block?.type !== 'text') return undefined
|
|
return block.text
|
|
}
|
|
|
|
function boundSingleText(content: readonly ContentBlock[], maxBytes: number): ContentBlock[] | undefined {
|
|
const text = rawSingleText(content)
|
|
if (text === undefined) return undefined
|
|
return [{
|
|
type: 'text',
|
|
text: fitWithSuffix(text, '', maxBytes, '\n[result truncated]'),
|
|
}]
|
|
}
|
|
|
|
function visibleOutputLimit(ctx: Context, exec: ToolExecution): number | undefined {
|
|
if (exec.name !== 'task_output' && exec.name !== 'task_kill') return undefined
|
|
const taskId = (exec.arguments as { task_id?: unknown } | null | undefined)?.task_id
|
|
if (typeof taskId !== 'string' || taskId.length === 0) return undefined
|
|
return ctx.tasks.list(exec.agent).find(snapshot => snapshot.id === taskId)?.outputLimitBytes
|
|
}
|
|
|
|
/** 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})`)
|
|
}
|
|
|
|
const outputLimits = new WeakMap<ToolExecution, number>()
|
|
ctx.on('tools/pre-execute', (exec, next) => {
|
|
const maxBytes = visibleOutputLimit(ctx, exec)
|
|
if (maxBytes !== undefined) outputLimits.set(exec, maxBytes)
|
|
return next()
|
|
}, { prepend: true })
|
|
const finalizeTaskContent: NonNullable<ToolDefinition['finalizeContent']> = (exec, result) => {
|
|
const maxBytes = outputLimits.get(exec) ?? visibleOutputLimit(ctx, exec)
|
|
outputLimits.delete(exec)
|
|
if (maxBytes === undefined) return undefined
|
|
if (exec.name === 'task_output' && !result.isError) {
|
|
// This definition owns and schema-validates the canonical value. Preserve
|
|
// its output/status split only while policy left the default rendering intact.
|
|
const value = result.value as unknown as { text: string; task: PublicTaskSnapshot }
|
|
const body = value.text.length > 0 ? value.text : '(no new output)'
|
|
const content = body.endsWith('\n') ? body.slice(0, -1) : body
|
|
const suffix = `\n${statusLine(value.task)}`
|
|
if (rawSingleText(result.content) === `${content}${suffix}`) {
|
|
return [{
|
|
type: 'text',
|
|
text: fitWithSuffix(content, suffix, maxBytes, '\n[output truncated]'),
|
|
}]
|
|
}
|
|
}
|
|
return boundSingleText(result.content, maxBytes)
|
|
}
|
|
|
|
// 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.
|
|
// Delivery into a tearing-down owner is well-defined: the loop treats
|
|
// disposal like any cancel, so the notice appends as durable idle context
|
|
// (still attached and persisted during owner cleanup, presented on resume);
|
|
// after detach it lands in an unreferenced in-memory log and is dropped
|
|
// with it.
|
|
ctx.tasks.onTaskDone((snapshot, owner) => {
|
|
if (snapshot.reported || owner === undefined) return
|
|
owner.inject({
|
|
content: [{
|
|
type: 'text',
|
|
text: fitCompletionNotice(snapshot),
|
|
}],
|
|
source: { kind: 'plugin', plugin: 'tool-tasks' },
|
|
})
|
|
})
|
|
|
|
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.' },
|
|
},
|
|
finalizeContent: finalizeTaskContent,
|
|
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.' },
|
|
},
|
|
finalizeContent: finalizeTaskContent,
|
|
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),
|
|
}))
|
|
}
|