2026-07-09 21:22:54 +08:00
/**
2026-07-15 21:08:58 +08:00
* 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.
2026-07-09 21:22:54 +08:00
* @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' ]
2026-07-15 21:08:58 +08:00
/** Configures bounded `task_output` waits. */
2026-07-09 21:22:54 +08:00
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 ) ,
} )
/**
2026-07-15 21:08:58 +08:00
* Render generic status with optional producer detail.
* @param snapshot - task state to render.
* @returns a bracketed status line.
2026-07-09 21:22:54 +08:00
*/
export function statusLine ( snapshot : TaskSnapshot ) : string {
return snapshot . detail !== undefined
? ` [status: ${ snapshot . status } , ${ snapshot . detail } ] `
: ` [status: ${ snapshot . status } ] `
}
2026-07-15 21:08:58 +08:00
/** Validate the non-empty constraint that SchemaSpec cannot express. */
2026-07-09 21:22:54 +08:00
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 )
}
2026-07-15 21:08:58 +08:00
/** Pending presentation shared by the three generic task controls. */
2026-07-09 21:22:54 +08:00
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 } ) ` )
}
2026-07-15 21:08:58 +08:00
// Producers may start work only while a control surface is attached.
2026-07-09 21:22:54 +08:00
ctx . tasks . attachSurface ( 'tool-tasks' )
2026-07-15 21:08:58 +08:00
// Cross-call guidance follows the bash section and precedes product sections.
2026-07-09 21:22:54 +08:00
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.' ,
} )
2026-07-15 21:08:58 +08:00
// Use the exact lifecycle owner; reusable ids could resolve to a replacement.
2026-07-15 11:40:37 +08:00
ctx . tasks . onTaskDone ( ( snapshot , owner ) = > {
if ( snapshot . reported || owner === undefined ) return
2026-07-09 21:22:54 +08:00
try {
2026-07-15 11:40:37 +08:00
owner . inject (
2026-07-09 21:22:54 +08:00
[ { 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 ) {
2026-07-15 21:08:58 +08:00
// Disposal may win the race after settlement; other injection failures surface.
2026-07-09 21:22:54 +08:00
if ( error instanceof Error && error . message . includes ( 'is disposed' ) ) return
throw error
}
} )
ctx . tools . register ( defineTool ( {
name : 'task_output' ,
2026-07-15 21:08:58 +08:00
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.
2026-07-09 21:22:54 +08:00
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.' } ,
} ,
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 )
const body = read . text . length > 0 ? read . text : '(no new output)'
const separator = body . endsWith ( '\n' ) ? '' : '\n'
return [ { type : 'text' , text : ` ${ body } ${ separator } ${ statusLine ( 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 : { } ,
execute ( _args , exec ) {
const tasks = ctx . tasks . list ( exec . agent )
const text = tasks . length === 0
? '(no background tasks)'
: tasks . map ( t = > ` ${ t . id } [ ${ t . kind } ] ${ t . status } — ${ t . label } ` ) . join ( '\n' )
return Promise . resolve ( [ { type : 'text' , text } ] )
} ,
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.' } ,
} ,
execute ( args , exec ) {
const id = validateTaskId ( args . task_id )
const result = ctx . tasks . kill ( id , exec . agent , args . reason )
2026-07-15 21:45:30 +08:00
if ( result === 'already-finished' ) {
2026-07-15 21:08:58 +08:00
// A snapshot describes terminal state without consuming pending output.
2026-07-09 21:22:54 +08:00
const snapshot = ctx . tasks . get ( id , exec . agent )
return Promise . resolve ( [ { type : 'text' , text : ` task ${ id } had already finished ${ statusLine ( snapshot ) } ` } ] )
}
return Promise . resolve ( [ { type : 'text' , text : ` requested cancellation of task ${ id } ` } ] )
} ,
presentCall : args = > presentTaskCall ( ` Kill background task ${ args . task_id } ` , 'execute' , args . task_id ) ,
} ) )
}