fix(pty): close review lifecycle gaps
This commit is contained in:
@@ -10,6 +10,8 @@ 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.
|
||||
|
||||
## 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.
|
||||
@@ -67,7 +69,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,6 +8,7 @@
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { TextRetainer } from '@deepseek-ai/dsh-retention'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { GenericCallView } from '@deepseek-ai/dsh-tools'
|
||||
import { TaskId } from '@deepseek-ai/dsh-tasks'
|
||||
@@ -41,6 +42,28 @@ export function statusLine(snapshot: TaskSnapshot): string {
|
||||
: `[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 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}`
|
||||
}
|
||||
|
||||
/** Validate the non-empty constraint that SchemaSpec cannot express. */
|
||||
function validateTaskId(value: string): TaskId {
|
||||
if (value.length === 0) {
|
||||
@@ -75,8 +98,13 @@ 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: `background task ${snapshot.id} (${snapshot.kind}: ${snapshot.label}) finished ${statusLine(snapshot)}. Read its output with task_output.` }],
|
||||
[{
|
||||
type: 'text',
|
||||
text: fitWithSuffix(prefix, suffix, snapshot.outputLimitBytes, '\n[notice truncated]'),
|
||||
}],
|
||||
{ source: { kind: 'plugin', plugin: 'tool-tasks' } },
|
||||
)
|
||||
} catch (error: unknown) {
|
||||
@@ -106,8 +134,16 @@ export function apply(ctx: Context, config: Config): void {
|
||||
}
|
||||
const read = ctx.tasks.read(id, exec.agent)
|
||||
const body = read.text.length > 0 ? read.text : '(no new output)'
|
||||
const separator = body.endsWith('\n') ? '' : '\n'
|
||||
return [{ type: 'text', text: `${body}${separator}${statusLine(read.snapshot)}` }]
|
||||
const content = body.endsWith('\n') ? body.slice(0, -1) : body
|
||||
return [{
|
||||
type: 'text',
|
||||
text: fitWithSuffix(
|
||||
content,
|
||||
`\n${statusLine(read.snapshot)}`,
|
||||
read.snapshot.outputLimitBytes,
|
||||
'\n[output truncated]',
|
||||
),
|
||||
}]
|
||||
},
|
||||
presentCall: args => presentTaskCall(`Read output from background task ${args.task_id}`, 'read', args.task_id),
|
||||
}))
|
||||
@@ -139,7 +175,15 @@ export function apply(ctx: Context, config: Config): void {
|
||||
if (result === 'already-finished') {
|
||||
// A snapshot describes terminal state without consuming pending output.
|
||||
const snapshot = ctx.tasks.get(id, exec.agent)
|
||||
return Promise.resolve([{ type: 'text', text: `task ${id} had already finished ${statusLine(snapshot)}` }])
|
||||
return Promise.resolve([{
|
||||
type: 'text',
|
||||
text: fitWithSuffix(
|
||||
`task ${id} had already finished`,
|
||||
` ${statusLine(snapshot)}`,
|
||||
snapshot.outputLimitBytes,
|
||||
'\n[notice truncated]',
|
||||
),
|
||||
}])
|
||||
}
|
||||
return Promise.resolve([{ type: 'text', text: `requested cancellation of task ${id}` }])
|
||||
},
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -131,6 +137,18 @@ 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('wait: true blocks until settlement and reports the terminal state', async () => {
|
||||
const { ctx } = await setup()
|
||||
const p = producer({ kind: 'subagent', label: 'research' })
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../util/retention"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user