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'
2026-07-28 13:55:59 +08:00
import { createUserMessage , type ContentBlock } from '@deepseek-ai/dsh-llm'
2026-07-22 22:37:20 +08:00
import { TextRetainer } from '@deepseek-ai/dsh-retention'
2026-07-09 21:22:54 +08:00
import { defineTool } from '@deepseek-ai/dsh-tools'
2026-07-23 02:53:43 +08:00
import type { GenericCallView , ToolDefinition , ToolExecution } from '@deepseek-ai/dsh-tools'
2026-07-09 21:22:54 +08:00
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-21 03:08:35 +08:00
/** 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 } : { } ,
}
}
2026-07-09 21:22:54 +08:00
/**
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
*/
2026-07-21 03:08:35 +08:00
export function statusLine ( snapshot : Pick < TaskSnapshot , 'status' | 'detail' > ) : string {
2026-07-09 21:22:54 +08:00
return snapshot . detail !== undefined
? ` [status: ${ snapshot . status } , ${ snapshot . detail } ] `
: ` [status: ${ snapshot . status } ] `
}
2026-07-22 22:37:20 +08:00
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
}
2026-07-23 01:38:10 +08:00
function retainHead ( text : string , maxBytes : number ) : string {
const retainer = new TextRetainer ( { kind : 'head' , maxBytes } )
retainer . push ( text )
return retainer . finish ( ) . text
}
2026-07-22 22:37:20 +08:00
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 } `
}
2026-07-23 01:38:10 +08:00
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
2026-07-23 03:15:15 +08:00
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 } `
2026-07-23 01:38:10 +08:00
}
2026-07-23 20:50:45 +08:00
function rawSingleText ( content : readonly ContentBlock [ ] ) : string | undefined {
2026-07-23 01:56:10 +08:00
if ( content . length !== 1 ) return undefined
const block = content [ 0 ]
if ( block ? . type !== 'text' ) return undefined
2026-07-23 20:50:45 +08:00
return block . text
}
function boundSingleText ( content : readonly ContentBlock [ ] , maxBytes : number ) : ContentBlock [ ] | undefined {
const text = rawSingleText ( content )
if ( text === undefined ) return undefined
2026-07-23 01:56:10 +08:00
return [ {
type : 'text' ,
2026-07-23 20:50:45 +08:00
text : fitWithSuffix ( text , '' , maxBytes , '\n[result truncated]' ) ,
2026-07-23 01:56:10 +08:00
} ]
}
2026-07-23 02:14:06 +08:00
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
2026-07-23 01:56:10 +08:00
}
2026-07-21 01:11:55 +08:00
/** Validate the non-empty constraint that ParameterSchemaSpec 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-23 01:56:10 +08:00
const outputLimits = new WeakMap < ToolExecution , number > ( )
2026-07-23 02:14:06 +08:00
ctx . on ( 'tools/pre-execute' , ( exec , next ) = > {
const maxBytes = visibleOutputLimit ( ctx , exec )
if ( maxBytes !== undefined ) outputLimits . set ( exec , maxBytes )
return next ( )
} , { prepend : true } )
2026-07-23 02:53:43 +08:00
const finalizeTaskContent : NonNullable < ToolDefinition [ 'finalizeContent' ] > = ( exec , result ) = > {
const maxBytes = outputLimits . get ( exec ) ? ? visibleOutputLimit ( ctx , exec )
2026-07-23 01:56:10 +08:00
outputLimits . delete ( exec )
2026-07-23 20:50:45 +08:00
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 )
2026-07-23 02:53:43 +08:00
}
2026-07-23 01:56:10 +08:00
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-26 22:08:35 +08:00
// 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.
2026-07-15 11:40:37 +08:00
ctx . tasks . onTaskDone ( ( snapshot , owner ) = > {
if ( snapshot . reported || owner === undefined ) return
2026-07-28 13:55:59 +08:00
owner . inject ( createUserMessage ( {
2026-07-26 22:08:35 +08:00
content : [ {
type : 'text' ,
text : fitCompletionNotice ( snapshot ) ,
} ] ,
source : { kind : 'plugin' , plugin : 'tool-tasks' } ,
2026-07-28 13:55:59 +08:00
} ) )
2026-07-09 21:22:54 +08:00
} )
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.' } ,
} ,
2026-07-23 02:53:43 +08:00
finalizeContent : finalizeTaskContent ,
2026-07-21 03:08:35 +08:00
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 ) } ` } ]
} ,
} ,
2026-07-09 21:22:54 +08:00
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 )
2026-07-21 03:08:35 +08:00
return { text : read.text , task : publicTask ( read . snapshot ) }
2026-07-09 21:22:54 +08:00
} ,
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 : { } ,
2026-07-21 03:08:35 +08:00
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' ) ,
} ] ,
} ,
2026-07-09 21:22:54 +08:00
execute ( _args , exec ) {
const tasks = ctx . tasks . list ( exec . agent )
2026-07-21 03:08:35 +08:00
return Promise . resolve ( tasks . map ( publicTask ) )
2026-07-09 21:22:54 +08:00
} ,
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.' } ,
} ,
2026-07-23 02:53:43 +08:00
finalizeContent : finalizeTaskContent ,
2026-07-21 03:08:35 +08:00
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 } ` ,
} ] ,
} ,
2026-07-09 21:22:54 +08:00
execute ( args , exec ) {
const id = validateTaskId ( args . task_id )
const result = ctx . tasks . kill ( id , exec . agent , args . reason )
2026-07-21 03:08:35 +08:00
// 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 ,
} )
2026-07-09 21:22:54 +08:00
} ,
presentCall : args = > presentTaskCall ( ` Kill background task ${ args . task_id } ` , 'execute' , args . task_id ) ,
} ) )
}