fix(tasks): keep bounded notices actionable

This commit is contained in:
Tianyi Cui
2026-07-23 01:38:10 +08:00
parent 6ad8e4501f
commit f8939daf0a
4 changed files with 61 additions and 6 deletions

View File

@@ -75,7 +75,7 @@ Stream reads share one task-scoped consuming cursor because the owning model is
The system prompt tells the model to retain task ids, continue independent work instead of busy-polling or duplicating a running task, collect relevant tasks before its final answer, and kill work that no longer matters. Completion injects a logged `context/message` into the exact owner's session; it becomes durable context for the next request but does not wake an idle agent.
The runtime marks a terminal task `reported` when a read or wait delivers it, when a live waiter has claimed delivery at settlement, or when the model explicitly kills it. Reported tasks do not inject redundant completion notices. Listener failures are logged independently, do not stop later listeners, and are not awaited by waiters or teardown. When a snapshot carries `outputLimitBytes`, `dsh-tool-tasks` reserves space for status or notice suffixes, preserves UTF-8 boundaries, and reuses an existing producer truncation marker rather than duplicating it.
The runtime marks a terminal task `reported` when a read or wait delivers it, when a live waiter has claimed delivery at settlement, or when the model explicitly kills it. Reported tasks do not inject redundant completion notices. Listener failures are logged independently, do not stop later listeners, and are not awaited by waiters or teardown. When a snapshot carries `outputLimitBytes`, `dsh-tool-tasks` preserves UTF-8 boundaries and reuses an existing producer truncation marker rather than duplicating it. Reads reserve status suffixes and retain the output tail; completion notices reserve the stable `background task <id>` prefix and `task_output` instruction before truncating variable kind, label, status, or detail, so the minimum PTY cap still identifies the task to collect.
## Producer opt-in

View File

@@ -10,11 +10,11 @@ The model-facing control surface for `ctx.tasks`: three kind-independent tools,
All three use generic ACP cards: `read` for output and list, `execute` for kill.
When a producer supplies `outputLimitBytes`, `task_output`, terminal `task_kill`, and completion notices cap the complete UTF-8 result after adding status or notice text. The output tail and control suffix are retained when they fit; an existing producer truncation marker is reused rather than duplicated. Producers that omit the field retain the existing unbounded control-surface behavior.
When a producer supplies `outputLimitBytes`, `task_output`, terminal `task_kill`, and completion notices cap the complete UTF-8 result after adding status or notice text. Reads retain the output tail and control suffix when they fit; a bounded completion notice instead reserves `background task <id>` and the `task_output` collection instruction before spending remaining bytes on its variable kind, label, status, and detail. An existing producer truncation marker is reused rather than duplicated. Producers that omit the field retain the existing unbounded control-surface behavior.
## Completion notices
An unreported completion injects `background task <id> (<kind>: <label>) finished [status: ...]. Read its output with task_output.` into the exact owner's session. Injection is durable context for the next request, not a wake-up. A kill or terminal read/wait marks delivery reported and suppresses the redundant notice; owner-disposal races are contained.
An unreported completion injects `background task <id> (<kind>: <label>) finished [status: ...]. Read its output with task_output.` into the exact owner's session. When bounded, the stable id prefix and collection command outrank variable label/detail so the notice remains actionable at PTY's supported 64-byte minimum. Injection is durable context for the next request, not a wake-up. A kill or terminal read/wait marks delivery reported and suppresses the redundant notice; owner-disposal races are contained.
## Config

View File

@@ -50,6 +50,12 @@ function retainTail(text: string, maxBytes: number): string {
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,
@@ -64,6 +70,20 @@ function fitWithSuffix(
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 retainHead(fixed, maxBytes)
return `${prefix}${retainHead(detail, maxBytes - fixedBytes)}${omitted}${action}`
}
/** Validate the non-empty constraint that SchemaSpec cannot express. */
function validateTaskId(value: string): TaskId {
if (value.length === 0) {
@@ -98,12 +118,10 @@ export function apply(ctx: Context, config: Config): void {
ctx.tasks.onTaskDone((snapshot, owner) => {
if (snapshot.reported || owner === undefined) return
try {
const prefix = `background task ${snapshot.id} (${snapshot.kind}: ${snapshot.label})`
const suffix = ` finished ${statusLine(snapshot)}. Read its output with task_output.`
owner.inject(
[{
type: 'text',
text: fitWithSuffix(prefix, suffix, snapshot.outputLimitBytes, '\n[notice truncated]'),
text: fitCompletionNotice(snapshot),
}],
{ source: { kind: 'plugin', plugin: 'tool-tasks' } },
)

View File

@@ -272,6 +272,43 @@ describe('completion notices', () => {
)
})
it('preserves task ids and collection guidance in bounded completion notices', async () => {
const { ctx } = await setup()
const inject = vi.fn()
const owner = fakeAgent(ctx, 'sess-1', inject)
const first = producer({
owner,
kind: 'subagent',
label: 'x'.repeat(1_000),
outputLimitBytes: 64,
})
ctx.tasks.start(first.spec)
first.settle({ status: 'completed', detail: 'd'.repeat(1_000) })
await tick()
expect(inject).toHaveBeenNthCalledWith(
1,
[{ type: 'text', text: 'background task subagent-1\n[notice truncated]\nDone; task_output.' }],
{ source: { kind: 'plugin', plugin: 'tool-tasks' } },
)
const second = producer({
owner,
kind: 'subagent',
label: 'x'.repeat(1_000),
outputLimitBytes: 80,
})
ctx.tasks.start(second.spec)
second.settle({ status: 'completed', detail: 'd'.repeat(1_000) })
await tick()
const content = inject.mock.calls[1]?.[0] as Array<{ type: string; text?: string }> | undefined
const notice = content?.[0]?.text ?? ''
expect(Buffer.byteLength(notice)).toBeLessThanOrEqual(80)
expect(notice).toContain('background task subagent-2 (subagent: xxxx')
expect(notice).toContain('[notice truncated]\nDone; task_output.')
})
it('suppresses the notice for a task the model already killed', async () => {
const { ctx } = await setup()
const inject = vi.fn()