fix(tasks): keep bounded notices actionable
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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' } },
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user