Merge remote-tracking branch 'origin/master' into feat/send-unify
# Conflicts: # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl # packages/cordis/tool-cordis/src/api-catalog.ts # packages/pty/pty-local/tests/index.spec.ts # packages/session-query/session-query/tests/tracing.spec.ts
This commit is contained in:
@@ -4,7 +4,7 @@ The process-local background task registry (`ctx.tasks`). It gives long-running
|
||||
|
||||
## Service API
|
||||
|
||||
- `start(spec): TaskId` validates the control surface, spec, and exact live owner before calling the producer's `run()` once. A starter throw leaves nothing registered; successful return commits without another failable step.
|
||||
- `start(spec): TaskId` validates the control surface, spec, exact live owner, and optional positive `outputLimitBytes` before calling the producer's `run()` once. A starter throw leaves nothing registered; successful return commits without another failable step.
|
||||
- `get(id, caller?)` and `list(caller?)` return non-consuming snapshots. Listing includes only caller-owned and unowned tasks.
|
||||
- `read(id, caller?)` consumes the single cursor for stream tasks and reads terminal output idempotently for final-output tasks.
|
||||
- `kill(id, caller?, reason?)` invokes producer cancellation before changing status. A cancellation throw leaves the task running; success changes it to `stopping` and marks terminal delivery reported.
|
||||
@@ -14,6 +14,8 @@ The process-local background task registry (`ctx.tasks`). It gives long-running
|
||||
|
||||
Owned access compares the task's `SessionId` with the caller's. Ids such as `bash-1` are predictable, so this fence is the boundary. Unowned tasks are open to callers and last until service disposal.
|
||||
|
||||
`outputLimitBytes` is producer-owned model-presentation policy carried unchanged into snapshots. A control surface applies it after adding status or notice metadata; the registry does not rewrite producer output or invent a default for producers that omit it.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
Tasks belong to their owner and backend, not the producer tool fiber, so producer and surface reloads do not stop them. The first task for an owner attaches one awaited effect to the exact `Agent` scope. Owner disposal cancels that object's tasks, awaits producer quiescence, and removes their snapshots; reused agent or session ids cannot redirect an old cleanup.
|
||||
|
||||
@@ -42,6 +42,7 @@ interface TrackedTask {
|
||||
id: TaskId
|
||||
kind: TaskKind
|
||||
label: string
|
||||
outputLimitBytes: number | undefined
|
||||
/** Exact lifecycle owner; session-id authorization is derived from it. */
|
||||
owner: Agent | undefined
|
||||
cancel: (reason?: string) => void
|
||||
@@ -104,6 +105,10 @@ export class TaskService extends Service {
|
||||
}
|
||||
if (spec.kind.length === 0) throw new Error('invalid task kind: expected a non-empty string')
|
||||
if (spec.label.length === 0) throw new Error('invalid task label: expected a non-empty string')
|
||||
if (spec.outputLimitBytes !== undefined
|
||||
&& (!Number.isSafeInteger(spec.outputLimitBytes) || spec.outputLimitBytes <= 0)) {
|
||||
throw new Error(`invalid outputLimitBytes: expected a positive safe integer, got ${JSON.stringify(spec.outputLimitBytes)}`)
|
||||
}
|
||||
if (spec.owner !== undefined) this.ensureOwnerCleanup(spec.owner)
|
||||
|
||||
const hooks = spec.run()
|
||||
@@ -117,6 +122,7 @@ export class TaskService extends Service {
|
||||
id,
|
||||
kind: spec.kind,
|
||||
label: spec.label,
|
||||
outputLimitBytes: spec.outputLimitBytes,
|
||||
owner: spec.owner,
|
||||
cancel: hooks.cancel.bind(hooks),
|
||||
readOutput: hooks.readOutput?.bind(hooks),
|
||||
@@ -329,6 +335,7 @@ export class TaskService extends Service {
|
||||
id: task.id,
|
||||
kind: task.kind,
|
||||
label: task.label,
|
||||
...task.outputLimitBytes !== undefined ? { outputLimitBytes: task.outputLimitBytes } : {},
|
||||
...ownerSession !== undefined ? { ownerSession } : {},
|
||||
status: task.status,
|
||||
...task.detail !== undefined ? { detail: task.detail } : {},
|
||||
|
||||
@@ -61,6 +61,11 @@ export interface TaskStart {
|
||||
kind: TaskKind
|
||||
/** One-line model-facing label (the command; the delegation description). */
|
||||
label: string
|
||||
/**
|
||||
* Optional UTF-8 byte cap for each complete model-facing completion notice or
|
||||
* output read, including control-surface status metadata.
|
||||
*/
|
||||
outputLimitBytes?: number
|
||||
/**
|
||||
* Owning live agent. Access is fenced by its session id, and agent disposal
|
||||
* cancels and awaits the task. The instance must be the one currently
|
||||
@@ -109,6 +114,8 @@ export interface TaskSnapshot {
|
||||
kind: TaskKind
|
||||
/** The producer-supplied one-line label. */
|
||||
label: string
|
||||
/** Producer-owned cap for complete model-facing notices and output reads. */
|
||||
outputLimitBytes?: number
|
||||
/**
|
||||
* Owner session id used for authorization and correlation; absent for
|
||||
* unowned tasks. Completion listeners receive the exact {@link Agent}
|
||||
|
||||
@@ -45,13 +45,19 @@ function producer(overrides: Partial<Omit<TaskStart, 'run'> & TaskHooks> = {}) {
|
||||
let settle!: (outcome: TaskOutcome) => void
|
||||
let reject!: (error: unknown) => void
|
||||
const cancels: (string | undefined)[] = []
|
||||
const { kind = 'bash', label = 'sleep 60', owner, ...hookOverrides } = overrides
|
||||
const { kind = 'bash', label = 'sleep 60', owner, outputLimitBytes, ...hookOverrides } = overrides
|
||||
const hooks: TaskHooks = {
|
||||
cancel(reason) { cancels.push(reason) },
|
||||
done: new Promise<TaskOutcome>((res, rej) => { settle = res; reject = rej }),
|
||||
...hookOverrides,
|
||||
}
|
||||
const spec: TaskStart = { kind, label, ...owner !== undefined ? { owner } : {}, run: () => hooks }
|
||||
const spec: TaskStart = {
|
||||
kind,
|
||||
label,
|
||||
...owner !== undefined ? { owner } : {},
|
||||
...outputLimitBytes !== undefined ? { outputLimitBytes } : {},
|
||||
run: () => hooks,
|
||||
}
|
||||
return { spec, settle, reject, cancels }
|
||||
}
|
||||
|
||||
@@ -86,10 +92,11 @@ describe('TaskService.start', () => {
|
||||
.toThrow('background tasks unavailable: no control surface is attached (load @deepseek-ai/dsh-tool-tasks)')
|
||||
})
|
||||
|
||||
it('rejects an empty kind and an empty label', async () => {
|
||||
it('rejects an empty kind, empty label, and invalid output limit', async () => {
|
||||
const ctx = await harness()
|
||||
expect(() => ctx.tasks.start(producer({ kind: '' as TaskKind }).spec)).toThrow('invalid task kind')
|
||||
expect(() => ctx.tasks.start(producer({ label: '' }).spec)).toThrow('invalid task label')
|
||||
expect(() => ctx.tasks.start(producer({ outputLimitBytes: 0 }).spec)).toThrow('outputLimitBytes')
|
||||
})
|
||||
|
||||
it('issues kind-prefixed ids from per-kind counters', async () => {
|
||||
@@ -119,6 +126,16 @@ describe('TaskService reads and settlement', () => {
|
||||
expect(read.snapshot.finishedAt).toBeTypeOf('number')
|
||||
})
|
||||
|
||||
it('projects a producer-owned model output limit into reads and snapshots', async () => {
|
||||
const ctx = await harness()
|
||||
const p = producer({ outputLimitBytes: 64, readOutput: () => 'delta' })
|
||||
const id = ctx.tasks.start(p.spec)
|
||||
expect(ctx.tasks.read(id)).toMatchObject({
|
||||
text: 'delta', snapshot: { outputLimitBytes: 64 },
|
||||
})
|
||||
expect(ctx.tasks.get(id)).toMatchObject({ outputLimitBytes: 64 })
|
||||
})
|
||||
|
||||
it('final-output kinds read empty while live, the outcome output idempotently once settled', async () => {
|
||||
const ctx = await harness()
|
||||
const p = producer({ kind: 'subagent', label: 'research task' })
|
||||
|
||||
@@ -12,9 +12,11 @@ All three use generic ACP cards: `read` for output and list, `execute` for kill.
|
||||
|
||||
Their canonical values are `{ text, task }`, `PublicTaskSnapshot[]`, and `{ outcome: 'cancellation-requested' | 'already-finished', task }`. A public snapshot carries id, kind, label, status/detail, and start/finish times; it deliberately omits `ownerSession` and the internal `reported` notice bit. Native renderers preserve the status and acknowledgement text above.
|
||||
|
||||
When a producer supplies `outputLimitBytes`, `task_output`, terminal `task_kill`, and completion notices cap the complete Native 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
|
||||
|
||||
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
|
||||
|
||||
@@ -69,7 +71,7 @@ Reads return output or `(no new output)` followed by `[status: <status>]` and op
|
||||
|
||||
#### Token effect
|
||||
|
||||
Results and notices remain in parent history until compaction. Stream reads do not repeat consumed output.
|
||||
Results and notices remain in parent history until compaction. Stream reads do not repeat consumed output; a producer-supplied `outputLimitBytes` bounds each complete read or notice.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
|
||||
@@ -26,21 +26,23 @@
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-retention": "^0.0.1",
|
||||
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tasks": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-retention": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tasks": "workspace:^",
|
||||
|
||||
@@ -8,8 +8,10 @@
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import { TextRetainer } from '@deepseek-ai/dsh-retention'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { GenericCallView } from '@deepseek-ai/dsh-tools'
|
||||
import type { GenericCallView, ToolDefinition, ToolExecution } from '@deepseek-ai/dsh-tools'
|
||||
import { TaskId } from '@deepseek-ai/dsh-tasks'
|
||||
import type { TaskSnapshot } from '@deepseek-ai/dsh-tasks'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
@@ -84,6 +86,80 @@ export function statusLine(snapshot: Pick<TaskSnapshot, 'status' | 'detail'>): s
|
||||
: `[status: ${snapshot.status}]`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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,
|
||||
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}`
|
||||
}
|
||||
|
||||
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 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 rawSingleText(content: readonly ContentBlock[]): string | undefined {
|
||||
if (content.length !== 1) return undefined
|
||||
const block = content[0]
|
||||
if (block?.type !== 'text') return undefined
|
||||
return block.text
|
||||
}
|
||||
|
||||
function boundSingleText(content: readonly ContentBlock[], maxBytes: number): ContentBlock[] | undefined {
|
||||
const text = rawSingleText(content)
|
||||
if (text === undefined) return undefined
|
||||
return [{
|
||||
type: 'text',
|
||||
text: fitWithSuffix(text, '', maxBytes, '\n[result truncated]'),
|
||||
}]
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
/** Validate the non-empty constraint that ParameterSchemaSpec cannot express. */
|
||||
function validateTaskId(value: string): TaskId {
|
||||
if (value.length === 0) {
|
||||
@@ -104,6 +180,33 @@ export function apply(ctx: Context, config: Config): void {
|
||||
throw new Error(`tool-tasks: waitTimeoutMs (${waitDefault}) exceeds maxWaitTimeoutMs (${waitCap})`)
|
||||
}
|
||||
|
||||
const outputLimits = new WeakMap<ToolExecution, number>()
|
||||
ctx.on('tools/pre-execute', (exec, next) => {
|
||||
const maxBytes = visibleOutputLimit(ctx, exec)
|
||||
if (maxBytes !== undefined) outputLimits.set(exec, maxBytes)
|
||||
return next()
|
||||
}, { prepend: true })
|
||||
const finalizeTaskContent: NonNullable<ToolDefinition['finalizeContent']> = (exec, result) => {
|
||||
const maxBytes = outputLimits.get(exec) ?? visibleOutputLimit(ctx, exec)
|
||||
outputLimits.delete(exec)
|
||||
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)
|
||||
}
|
||||
|
||||
// Producers may start work only while a control surface is attached.
|
||||
ctx.tasks.attachSurface('tool-tasks')
|
||||
|
||||
@@ -119,7 +222,10 @@ export function apply(ctx: Context, config: Config): void {
|
||||
if (snapshot.reported || owner === undefined) return
|
||||
try {
|
||||
owner.inject(
|
||||
[{ type: 'text', text: `background task ${snapshot.id} (${snapshot.kind}: ${snapshot.label}) finished ${statusLine(snapshot)}. Read its output with task_output.` }],
|
||||
[{
|
||||
type: 'text',
|
||||
text: fitCompletionNotice(snapshot),
|
||||
}],
|
||||
{ source: { kind: 'plugin', plugin: 'tool-tasks' } },
|
||||
)
|
||||
} catch (error: unknown) {
|
||||
@@ -141,6 +247,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
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.' },
|
||||
},
|
||||
finalizeContent: finalizeTaskContent,
|
||||
output: {
|
||||
schema: {
|
||||
type: 'object',
|
||||
@@ -195,6 +302,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
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.' },
|
||||
},
|
||||
finalizeContent: finalizeTaskContent,
|
||||
output: {
|
||||
schema: {
|
||||
type: 'object',
|
||||
|
||||
@@ -6,7 +6,7 @@ import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import TaskService from '@deepseek-ai/dsh-tasks'
|
||||
import TaskService, { TaskId } from '@deepseek-ai/dsh-tasks'
|
||||
import type { TaskHooks, TaskOutcome, TaskSnapshot, TaskStart } from '@deepseek-ai/dsh-tasks'
|
||||
import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
|
||||
import { statusLine } from '@deepseek-ai/dsh-tool-tasks'
|
||||
@@ -52,13 +52,19 @@ function detachAgent(agent: Agent): void {
|
||||
function producer(overrides: Partial<Omit<TaskStart, 'run'> & TaskHooks> = {}) {
|
||||
let settle!: (outcome: TaskOutcome) => void
|
||||
const cancels: (string | undefined)[] = []
|
||||
const { kind = 'bash', label = 'sleep 60', owner, ...hookOverrides } = overrides
|
||||
const { kind = 'bash', label = 'sleep 60', owner, outputLimitBytes, ...hookOverrides } = overrides
|
||||
const hooks: TaskHooks = {
|
||||
cancel(reason) { cancels.push(reason) },
|
||||
done: new Promise<TaskOutcome>((res) => { settle = res }),
|
||||
...hookOverrides,
|
||||
}
|
||||
const spec: TaskStart = { kind, label, ...owner !== undefined ? { owner } : {}, run: () => hooks }
|
||||
const spec: TaskStart = {
|
||||
kind,
|
||||
label,
|
||||
...owner !== undefined ? { owner } : {},
|
||||
...outputLimitBytes !== undefined ? { outputLimitBytes } : {},
|
||||
run: () => hooks,
|
||||
}
|
||||
return { spec, settle, cancels }
|
||||
}
|
||||
|
||||
@@ -140,6 +146,118 @@ describe('task_output', () => {
|
||||
expect(text(await call(ctx, 'task_output', { task_id: 'subagent-1' }))).toBe('the answer\n[status: completed, completed]')
|
||||
})
|
||||
|
||||
it('applies a producer limit to the complete body and status result', async () => {
|
||||
const { ctx } = await setup()
|
||||
ctx.tasks.start(producer({
|
||||
outputLimitBytes: 48,
|
||||
readOutput: () => '界'.repeat(100),
|
||||
}).spec)
|
||||
|
||||
const output = text(await call(ctx, 'task_output', { task_id: 'bash-1' }))
|
||||
expect(Buffer.byteLength(output)).toBeLessThanOrEqual(48)
|
||||
expect(output).toContain('[status: running]')
|
||||
})
|
||||
|
||||
it('preserves empty and newline-terminated output under a producer limit', async () => {
|
||||
const { ctx } = await setup()
|
||||
const chunks = ['', 'line\n']
|
||||
ctx.tasks.start(producer({
|
||||
outputLimitBytes: 64,
|
||||
readOutput: () => chunks.shift() ?? '',
|
||||
}).spec)
|
||||
|
||||
expect(text(await call(ctx, 'task_output', { task_id: 'bash-1' })))
|
||||
.toBe('(no new output)\n[status: running]')
|
||||
expect(text(await call(ctx, 'task_output', { task_id: 'bash-1' })))
|
||||
.toBe('line\n[status: running]')
|
||||
})
|
||||
|
||||
it('bounds post-policy output without restoring the canonical status rendering', async () => {
|
||||
const { ctx } = await setup()
|
||||
ctx.tasks.start(producer({
|
||||
outputLimitBytes: 64,
|
||||
readOutput: () => 'canonical output',
|
||||
}).spec)
|
||||
ctx.on('tools/post-execute', (exec, _result, next) => {
|
||||
if (exec.name !== 'task_output') return next()
|
||||
return Promise.resolve({ kind: 'accept', content: [{ type: 'text', text: 'p'.repeat(1_000) }] })
|
||||
})
|
||||
|
||||
const result = await call(ctx, 'task_output', { task_id: 'bash-1' })
|
||||
expect(Buffer.byteLength(text(result))).toBeLessThanOrEqual(64)
|
||||
expect(text(result)).toContain('[result truncated]')
|
||||
expect(text(result)).not.toContain('[status: running]')
|
||||
})
|
||||
|
||||
it('applies a producer limit to a normalized read failure', async () => {
|
||||
const { ctx } = await setup()
|
||||
ctx.tasks.start(producer({
|
||||
outputLimitBytes: 64,
|
||||
readOutput: () => { throw new Error('read failed: '.repeat(100)) },
|
||||
}).spec)
|
||||
|
||||
const result = await call(ctx, 'task_output', { task_id: 'bash-1' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(Buffer.byteLength(text(result))).toBeLessThanOrEqual(64)
|
||||
expect(text(result)).toContain('[result truncated]')
|
||||
})
|
||||
|
||||
it('bounds pre-, around-, and post-execute policy outcomes and failures', async () => {
|
||||
const { ctx } = await setup()
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
ctx.tasks.start(producer({ outputLimitBytes: 64 }).spec)
|
||||
}
|
||||
ctx.on('tools/pre-execute', async (exec, next) => {
|
||||
const taskId = (exec.arguments as { task_id?: unknown }).task_id
|
||||
if (taskId === 'bash-1') return { kind: 'deny', reason: 'd'.repeat(1_000) }
|
||||
if (taskId === 'bash-3') throw new Error(`pre failed: ${'p'.repeat(1_000)}`)
|
||||
return next()
|
||||
})
|
||||
ctx.on('tools/execute', async (exec, next) => {
|
||||
const taskId = (exec.arguments as { task_id?: unknown }).task_id
|
||||
if (taskId === 'bash-2') {
|
||||
return {
|
||||
content: [],
|
||||
isError: false,
|
||||
value: {
|
||||
text: 'a'.repeat(1_000),
|
||||
task: {
|
||||
id: 'bash-2', kind: 'bash', label: 'sleep 60', status: 'running', startedAt: 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
if (taskId === 'bash-4') throw new Error(`around failed: ${'e'.repeat(1_000)}`)
|
||||
return next()
|
||||
})
|
||||
ctx.on('tools/post-execute', async (exec, _result, next) => {
|
||||
const taskId = (exec.arguments as { task_id?: unknown }).task_id
|
||||
if (taskId === 'bash-5') throw new Error(`post failed: ${'o'.repeat(1_000)}`)
|
||||
return next()
|
||||
})
|
||||
|
||||
const denied = await call(ctx, 'task_output', { task_id: 'bash-1' })
|
||||
expect(denied.isError).toBe(true)
|
||||
expect(Buffer.byteLength(text(denied))).toBeLessThanOrEqual(64)
|
||||
expect(text(denied)).toContain('[result truncated]')
|
||||
|
||||
const shortCircuited = await call(ctx, 'task_output', { task_id: 'bash-2' })
|
||||
expect(shortCircuited.isError).toBe(false)
|
||||
expect(Buffer.byteLength(text(shortCircuited))).toBeLessThanOrEqual(64)
|
||||
expect(text(shortCircuited)).toContain('[output truncated]')
|
||||
|
||||
const failures = [
|
||||
await call(ctx, 'task_output', { task_id: 'bash-3' }),
|
||||
await call(ctx, 'task_output', { task_id: 'bash-4' }),
|
||||
await call(ctx, 'task_output', { task_id: 'bash-5' }),
|
||||
]
|
||||
for (const failure of failures) {
|
||||
expect(failure.isError).toBe(true)
|
||||
expect(Buffer.byteLength(text(failure))).toBeLessThanOrEqual(64)
|
||||
expect(text(failure)).toContain('[result truncated]')
|
||||
}
|
||||
})
|
||||
|
||||
it('wait: true blocks until settlement and reports the terminal state', async () => {
|
||||
const { ctx } = await setup()
|
||||
const p = producer({ kind: 'subagent', label: 'research' })
|
||||
@@ -222,6 +340,73 @@ describe('task_kill', () => {
|
||||
expect(p.cancels).toEqual(['superseded'])
|
||||
})
|
||||
|
||||
it('applies the producer output limit to a cancellation acknowledgement', async () => {
|
||||
const { ctx } = await setup()
|
||||
const p = producer({ outputLimitBytes: 8 })
|
||||
ctx.tasks.start(p.spec)
|
||||
|
||||
const result = await call(ctx, 'task_kill', { task_id: 'bash-1' })
|
||||
expect(Buffer.byteLength(text(result))).toBeLessThanOrEqual(8)
|
||||
expect(p.cancels).toEqual([undefined])
|
||||
})
|
||||
|
||||
it('applies the producer output limit to a normalized cancellation failure', async () => {
|
||||
const { ctx } = await setup()
|
||||
ctx.tasks.start(producer({
|
||||
outputLimitBytes: 64,
|
||||
cancel: () => { throw new Error('cancel failed: '.repeat(100)) },
|
||||
}).spec)
|
||||
|
||||
const result = await call(ctx, 'task_kill', { task_id: 'bash-1' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(Buffer.byteLength(text(result))).toBeLessThanOrEqual(64)
|
||||
expect(text(result)).toContain('[result truncated]')
|
||||
expect(ctx.tasks.get(TaskId('bash-1'))).toMatchObject({ status: 'running', reported: false })
|
||||
})
|
||||
|
||||
it('bounds single-text post policy while preserving structured policy results', async () => {
|
||||
const { ctx } = await setup()
|
||||
ctx.on('tools/post-execute', (exec, _result, next) => {
|
||||
if (exec.name !== 'task_kill') return next()
|
||||
const reason = (exec.arguments as { reason?: unknown }).reason
|
||||
if (reason === 'replace') {
|
||||
return Promise.resolve({ kind: 'accept', content: [{ type: 'text', text: 'r'.repeat(1_000) }] })
|
||||
}
|
||||
if (reason === 'block') {
|
||||
return Promise.resolve({ kind: 'block', feedback: [{ type: 'text', text: 'b'.repeat(1_000) }] })
|
||||
}
|
||||
if (reason === 'multi') {
|
||||
return Promise.resolve({
|
||||
kind: 'block',
|
||||
feedback: [{ type: 'text', text: 'first' }, { type: 'text', text: 'second' }],
|
||||
})
|
||||
}
|
||||
if (reason === 'reasoning') {
|
||||
return Promise.resolve({ kind: 'block', feedback: [{ type: 'reasoning', text: 'policy detail' }] })
|
||||
}
|
||||
return next()
|
||||
})
|
||||
for (let index = 0; index < 4; index += 1) {
|
||||
ctx.tasks.start(producer({ outputLimitBytes: 64 }).spec)
|
||||
}
|
||||
|
||||
const replaced = await call(ctx, 'task_kill', { task_id: 'bash-1', reason: 'replace' })
|
||||
expect(replaced.isError).toBe(false)
|
||||
expect(Buffer.byteLength(text(replaced))).toBeLessThanOrEqual(64)
|
||||
expect(text(replaced)).toContain('[result truncated]')
|
||||
|
||||
const blocked = await call(ctx, 'task_kill', { task_id: 'bash-2', reason: 'block' })
|
||||
expect(blocked.isError).toBe(true)
|
||||
expect(Buffer.byteLength(text(blocked))).toBeLessThanOrEqual(64)
|
||||
expect(text(blocked)).toContain('[result truncated]')
|
||||
|
||||
const multi = await call(ctx, 'task_kill', { task_id: 'bash-3', reason: 'multi' })
|
||||
expect(multi.content).toEqual([{ type: 'text', text: 'first' }, { type: 'text', text: 'second' }])
|
||||
|
||||
const reasoning = await call(ctx, 'task_kill', { task_id: 'bash-4', reason: 'reasoning' })
|
||||
expect(reasoning.content).toEqual([{ type: 'reasoning', text: 'policy detail' }])
|
||||
})
|
||||
|
||||
it('reports an already-finished task without consuming its pending delta', async () => {
|
||||
const { ctx } = await setup()
|
||||
let delta = 'unread tail'
|
||||
@@ -276,6 +461,90 @@ 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('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()
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../util/retention"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user