feat(tasks): wake an idle owner when a background task completes

Completion notices went through agent.inject(), which never reserves a
driver, so a task settling after its turn closed left the notice parked
until unrelated input woke the agent — while the same prompt told the
model not to poll for it.

An unreported completion now picks its lane from the owner's state: a
busy owner is injected as before, an idle owner is woken with
followup(). This adopts the delivery rule the subagent continuation
manager already ships. maxConsecutiveWakes bounds the self-exciting
chain and is reset by user-authored input; completionDelivery: quiet
restores the old lane for deterministic transcripts.

Teardown cancellation now claims the terminal report the way kill()
already does, so an owner being destroyed is never woken, and settle()
announces completion last so a reporter that opens a turn synchronously
sees a committed record.
This commit is contained in:
Yichen Jiang
2026-08-11 19:36:49 +08:00
parent 1d12ae62e7
commit d78b796a51
25 changed files with 531 additions and 96 deletions

View File

@@ -15,21 +15,40 @@ import type { GenericCallView, ToolDefinition, ToolExecution } from '@deepseek-a
import { TaskId } from '@deepseek-ai/dsh-tasks'
import type { TaskSnapshot } from '@deepseek-ai/dsh-tasks'
import type {} from '@deepseek-ai/dsh-system-prompt'
import type {} from '@deepseek-ai/dsh-agent'
export const name = 'tool-tasks'
export const inject = ['tools', 'tasks', 'systemPrompt']
/** Configures bounded `task_output` waits. */
/**
* How an unreported completion reaches an owner that is already idle: `wakeup`
* opens a turn for it, `quiet` leaves it pending until something else wakes the
* owner. A busy owner is injected either way.
*/
export type CompletionDelivery = 'quiet' | 'wakeup'
/** Configures bounded `task_output` waits and completion-notice delivery. */
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
/** Whether a completion opens a turn on an idle owner (default `wakeup`). */
completionDelivery?: CompletionDelivery
/**
* Turns one owner may have opened by completion wakes before the next
* notice degrades to injection, reset by any user-authored input (default 3).
* Bounds the self-exciting chain where a woken turn starts the task whose
* completion wakes it again.
*/
maxConsecutiveWakes?: number
}
export const Config: z<Config> = z.object({
waitTimeoutMs: z.number().min(1).default(30_000),
maxWaitTimeoutMs: z.number().min(1).default(600_000),
completionDelivery: z.union(['quiet', 'wakeup'] as const).default('wakeup'),
maxConsecutiveWakes: z.number().min(1).default(3),
})
/** Task state safe for model-authored programs; ownership/bookkeeping fields are omitted. */
@@ -185,9 +204,21 @@ function presentTaskCall(title: string, kind: 'read' | 'execute', rawInput?: str
export function apply(ctx: Context, config: Config): void {
const waitDefault = config.waitTimeoutMs ?? 30_000
const waitCap = config.maxWaitTimeoutMs ?? 600_000
const delivery = config.completionDelivery ?? 'wakeup'
const wakeBudget = config.maxConsecutiveWakes ?? 3
// Turns this plugin opened on each owner since that owner last consumed
// human input. Keyed by the exact Agent, so a same-session replacement
// starts with a full budget.
const spentWakes = new WeakMap<object, number>()
if (waitDefault > waitCap) {
throw new Error(`tool-tasks: waitTimeoutMs (${waitDefault}) exceeds maxWaitTimeoutMs (${waitCap})`)
}
ctx.on('agent/inbox/claimed', ({ agent, message }) => {
// Claiming is the point the human's input actually enters a step; a notice
// this plugin itself queued must not refill the budget it just spent.
if (message.source.kind === 'user') spentWakes.delete(agent)
})
const outputLimits = new WeakMap<ToolExecution, number>()
ctx.on('tools/pre-execute', (exec, next) => {
@@ -227,16 +258,18 @@ export function apply(ctx: Context, config: Config): void {
})
// Use the exact lifecycle owner; reusable ids could resolve to a replacement.
// Delivery targets the exact lifecycle owner. The notice waits in its
// next-step inbox until another step claims it; disposal before that
// boundary discards it with the owner.
// A busy owner is injected: the notice waits in its next-step inbox, which
// the turn cannot close over, so tasks settling together cost one step. An
// idle owner is woken instead, because an unclaimed notice is a completion
// the model never learns about. Either way, disposal before the claim
// discards it with the owner, and teardown settlements arrive `reported`.
//
// The registry routes each settlement to the listeners its owner's scope
// chain reaches, so a mount under one preset never sees another preset's
// agents; this listener owns delivery, not the choice of whom to deliver to.
ctx.tasks.onTaskDone((snapshot, owner) => {
if (snapshot.reported || owner === undefined) return
owner.inject(createUserMessage({
const message = createUserMessage({
content: [{
type: 'text',
text: fitCompletionNotice(snapshot),
@@ -247,7 +280,14 @@ export function apply(ctx: Context, config: Config): void {
form: 'notice',
summary: completionSummary(snapshot),
},
}))
})
const spent = spentWakes.get(owner) ?? 0
if (delivery === 'wakeup' && owner.status === 'idle' && spent < wakeBudget) {
spentWakes.set(owner, spent + 1)
owner.followup(message)
return
}
owner.inject(message)
})
ctx.tools.register(defineTool({