fix(tools): preserve bounded fallback guidance
This commit is contained in:
@@ -10,7 +10,7 @@ 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. 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. A prepended pre-execute listener captures the caller-visible task before policy, and each task-control definition's final-content callback applies its producer cap to single-text denials, short-circuits, normalized tool or pipeline failures, replacements, and blocks; structured multi-block policy results retain their shape. 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, detail, and truncation marker. A prepended pre-execute listener captures the caller-visible task before policy, and each task-control definition's final-content callback applies its producer cap to single-text denials, short-circuits, normalized tool or pipeline failures, replacements, and blocks; structured multi-block policy results retain their shape. An existing producer truncation marker is reused rather than duplicated. Producers that omit the field retain the existing unbounded control-surface behavior.
|
||||
|
||||
## Completion notices
|
||||
|
||||
|
||||
@@ -81,8 +81,17 @@ function fitCompletionNotice(snapshot: TaskSnapshot): string {
|
||||
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}`
|
||||
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}`
|
||||
}
|
||||
|
||||
function boundSingleText(content: readonly ContentBlock[], maxBytes: number): ContentBlock[] | undefined {
|
||||
|
||||
@@ -424,6 +424,53 @@ describe('completion notices', () => {
|
||||
expect(notice).toContain('[notice truncated]\nDone; task_output.')
|
||||
})
|
||||
|
||||
it('keeps the complete PTY task id and collection action at the minimum PTY limit', async () => {
|
||||
const { ctx } = await setup()
|
||||
for (let index = 0; index < 99; index += 1) {
|
||||
const prior = producer({ kind: 'pty-send' })
|
||||
ctx.tasks.start(prior.spec)
|
||||
prior.settle({ status: 'completed' })
|
||||
}
|
||||
const inject = vi.fn()
|
||||
const owner = fakeAgent(ctx, 'sess-1', inject)
|
||||
const target = producer({
|
||||
owner,
|
||||
kind: 'pty-send',
|
||||
label: 'x'.repeat(1_000),
|
||||
outputLimitBytes: 64,
|
||||
})
|
||||
ctx.tasks.start(target.spec)
|
||||
|
||||
target.settle({ status: 'completed', detail: 'd'.repeat(1_000) })
|
||||
await tick()
|
||||
|
||||
const content = inject.mock.calls[0]?.[0] as Array<{ type: string; text?: string }> | undefined
|
||||
const notice = content?.[0]?.text ?? ''
|
||||
expect(Buffer.byteLength(notice)).toBeLessThanOrEqual(64)
|
||||
expect(notice).toBe('background task pty-send-100\nDone; task_output.')
|
||||
})
|
||||
|
||||
it('reserves the collection-action tail when a producer supplies a smaller budget', async () => {
|
||||
const { ctx } = await setup()
|
||||
const inject = vi.fn()
|
||||
const owner = fakeAgent(ctx, 'sess-1', inject)
|
||||
const tiny = producer({ owner, kind: 'pty-send', label: 'x'.repeat(100), outputLimitBytes: 8 })
|
||||
const short = producer({ owner, kind: 'pty-send', label: 'x'.repeat(100), outputLimitBytes: 32 })
|
||||
ctx.tasks.start(tiny.spec)
|
||||
ctx.tasks.start(short.spec)
|
||||
|
||||
tiny.settle({ status: 'completed' })
|
||||
short.settle({ status: 'completed' })
|
||||
await tick()
|
||||
|
||||
const tinyNotice = (inject.mock.calls[0]?.[0] as Array<{ text?: string }> | undefined)?.[0]?.text ?? ''
|
||||
const shortNotice = (inject.mock.calls[1]?.[0] as Array<{ text?: string }> | undefined)?.[0]?.text ?? ''
|
||||
expect(Buffer.byteLength(tinyNotice)).toBeLessThanOrEqual(8)
|
||||
expect(tinyNotice).toBe('_output.')
|
||||
expect(Buffer.byteLength(shortNotice)).toBeLessThanOrEqual(32)
|
||||
expect(shortNotice).toBe('background ta\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