fix(pty): close review lifecycle gaps

This commit is contained in:
Tianyi Cui
2026-07-22 22:37:20 +08:00
parent 1167ea71b7
commit 57a47b1fb3
47 changed files with 940 additions and 192 deletions

View File

@@ -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

View File

@@ -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:^",

View File

@@ -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}` }])
},

View File

@@ -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' })

View File

@@ -17,6 +17,9 @@
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../util/retention"
},
{
"path": "../../core/agent"
},