Merge commit 'refs/codex-unblock/20260723/master' into worktree/pty-review-fixes

# Conflicts:
#	.agents/notes/implemented/feature/2026-06-30-interception-seams.md
#	docs/config-catalog.md
#	docs/cordis-catalog/services.md
#	docs/core-data-structures/tools.md
#	docs/event-producer-consumer.md
#	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/core/tools/README.md
#	packages/core/tools/src/index.ts
#	packages/core/tools/src/schema.ts
#	packages/core/tools/tests/tools.spec.ts
#	packages/pty/tool-pty/README.md
#	packages/pty/tool-pty/src/index.ts
#	packages/pty/tool-pty/src/render.ts
#	packages/tasks/tool-tasks/README.md
#	packages/tasks/tool-tasks/src/index.ts
This commit is contained in:
Tianyi Cui
2026-07-23 20:50:45 +08:00
584 changed files with 27686 additions and 9816 deletions

View File

@@ -10,7 +10,9 @@ 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, 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.
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

View File

@@ -32,12 +32,55 @@ export const Config: z<Config> = z.object({
maxWaitTimeoutMs: z.number().min(1).default(600_000),
})
/** Task state safe for model-authored programs; ownership/bookkeeping fields are omitted. */
export interface PublicTaskSnapshot {
id: string
kind: string
label: string
status: TaskSnapshot['status']
detail?: string
startedAt: number
finishedAt?: number
}
/** Shared schema for task-control outputs. */
const PUBLIC_TASK_SCHEMA = {
type: 'object',
additionalProperties: false,
properties: {
id: { type: 'string', required: true },
kind: { type: 'string', required: true },
label: { type: 'string', required: true },
status: {
type: 'string',
required: true,
enum: ['running', 'stopping', 'completed', 'killed', 'failed'],
},
detail: { type: 'string' },
startedAt: { type: 'integer', required: true },
finishedAt: { type: 'integer' },
},
} as const
/** Remove task ownership and notification bookkeeping from a registry snapshot. */
function publicTask(snapshot: TaskSnapshot): PublicTaskSnapshot {
return {
id: snapshot.id,
kind: snapshot.kind,
label: snapshot.label,
status: snapshot.status,
...snapshot.detail !== undefined ? { detail: snapshot.detail } : {},
startedAt: snapshot.startedAt,
...snapshot.finishedAt !== undefined ? { finishedAt: snapshot.finishedAt } : {},
}
}
/**
* Render generic status with optional producer detail.
* @param snapshot - task state to render.
* @returns a bracketed status line.
*/
export function statusLine(snapshot: TaskSnapshot): string {
export function statusLine(snapshot: Pick<TaskSnapshot, 'status' | 'detail'>): string {
return snapshot.detail !== undefined
? `[status: ${snapshot.status}, ${snapshot.detail}]`
: `[status: ${snapshot.status}]`
@@ -94,13 +137,19 @@ function fitCompletionNotice(snapshot: TaskSnapshot): string {
return `${retainHead(prefix, maxBytes - actionBytes)}${action}`
}
function boundSingleText(content: readonly ContentBlock[], maxBytes: number): ContentBlock[] | undefined {
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(block.text, '', maxBytes, '\n[result truncated]'),
text: fitWithSuffix(text, '', maxBytes, '\n[result truncated]'),
}]
}
@@ -111,7 +160,7 @@ function visibleOutputLimit(ctx: Context, exec: ToolExecution): number | undefin
return ctx.tasks.list(exec.agent).find(snapshot => snapshot.id === taskId)?.outputLimitBytes
}
/** Validate the non-empty constraint that SchemaSpec cannot express. */
/** Validate the non-empty constraint that ParameterSchemaSpec cannot express. */
function validateTaskId(value: string): TaskId {
if (value.length === 0) {
throw new Error(`invalid task_id: expected a non-empty string, got ${JSON.stringify(value)}`)
@@ -140,7 +189,22 @@ export function apply(ctx: Context, config: Config): void {
const finalizeTaskContent: NonNullable<ToolDefinition['finalizeContent']> = (exec, result) => {
const maxBytes = outputLimits.get(exec) ?? visibleOutputLimit(ctx, exec)
outputLimits.delete(exec)
return maxBytes === undefined ? undefined : boundSingleText(result.content, maxBytes)
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.
@@ -184,6 +248,21 @@ export function apply(ctx: Context, config: Config): void {
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',
additionalProperties: false,
properties: {
text: { type: 'string', required: true },
task: { ...PUBLIC_TASK_SCHEMA, required: true },
},
},
render: (_args, value) => {
const body = value.text.length > 0 ? value.text : '(no new output)'
const separator = body.endsWith('\n') ? '' : '\n'
return [{ type: 'text', text: `${body}${separator}${statusLine(value.task)}` }]
},
},
async execute(args, exec) {
const id = validateTaskId(args.task_id)
if (args.wait === true) {
@@ -191,17 +270,7 @@ export function apply(ctx: Context, config: Config): void {
await ctx.tasks.wait(id, timeout, exec.agent, exec.signal)
}
const read = ctx.tasks.read(id, exec.agent)
const body = read.text.length > 0 ? read.text : '(no new output)'
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]',
),
}]
return { text: read.text, task: publicTask(read.snapshot) }
},
presentCall: args => presentTaskCall(`Read output from background task ${args.task_id}`, 'read', args.task_id),
}))
@@ -210,12 +279,18 @@ export function apply(ctx: Context, config: Config): void {
name: 'task_list',
description: 'List your background tasks (running and finished) with their ids, kinds, and statuses.',
parameters: {},
output: {
schema: { type: 'array', items: PUBLIC_TASK_SCHEMA },
render: (_args, tasks) => [{
type: 'text',
text: tasks.length === 0
? '(no background tasks)'
: tasks.map(t => `${t.id} [${t.kind}] ${t.status}${t.label}`).join('\n'),
}],
},
execute(_args, exec) {
const tasks = ctx.tasks.list(exec.agent)
const text = tasks.length === 0
? '(no background tasks)'
: tasks.map(t => `${t.id} [${t.kind}] ${t.status}${t.label}`).join('\n')
return Promise.resolve([{ type: 'text', text }])
return Promise.resolve(tasks.map(publicTask))
},
presentCall: () => presentTaskCall('List background tasks', 'read'),
}))
@@ -228,31 +303,35 @@ export function apply(ctx: Context, config: Config): void {
reason: { type: 'string', description: 'Optional short reason, recorded in the log and forwarded to the task.' },
},
finalizeContent: finalizeTaskContent,
output: {
schema: {
type: 'object',
additionalProperties: false,
properties: {
outcome: {
type: 'string',
required: true,
enum: ['cancellation-requested', 'already-finished'],
},
task: { ...PUBLIC_TASK_SCHEMA, required: true },
},
},
render: (_args, value) => [{
type: 'text',
text: value.outcome === 'already-finished'
? `task ${value.task.id} had already finished ${statusLine(value.task)}`
: `requested cancellation of task ${value.task.id}`,
}],
},
execute(args, exec) {
const id = validateTaskId(args.task_id)
const snapshot = ctx.tasks.get(id, exec.agent)
const result = ctx.tasks.kill(id, exec.agent, args.reason)
if (result === 'already-finished') {
// A snapshot describes terminal state without consuming pending output.
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: fitWithSuffix(
`requested cancellation of task ${id}`,
'',
snapshot.outputLimitBytes,
'\n[notice truncated]',
),
}])
// A snapshot describes current state without consuming pending output.
const snapshot = publicTask(ctx.tasks.get(id, exec.agent))
return Promise.resolve({
outcome: result === 'already-finished' ? 'already-finished' as const : 'cancellation-requested' as const,
task: snapshot,
})
},
presentCall: args => presentTaskCall(`Kill background task ${args.task_id}`, 'execute', args.task_id),
}))

View File

@@ -122,7 +122,16 @@ describe('task_output', () => {
ctx.tasks.start(producer({ readOutput: () => chunks.shift() ?? '' }).spec)
// A body already ending in a newline gets no doubled separator.
expect(text(await call(ctx, 'task_output', { task_id: 'bash-1' }))).toBe('line one\n[status: running]')
const first = await call(ctx, 'task_output', { task_id: 'bash-1' })
if (first.isError) throw new Error('expected task_output success')
const firstValue = first.value as { text: string; task: Record<string, unknown> }
expect(firstValue).toMatchObject({
text: 'line one\n',
task: { id: 'bash-1', kind: 'bash', label: 'sleep 60', status: 'running' },
})
expect(firstValue.task).not.toHaveProperty('ownerSession')
expect(firstValue.task).not.toHaveProperty('reported')
expect(text(first)).toBe('line one\n[status: running]')
expect(text(await call(ctx, 'task_output', { task_id: 'bash-1' }))).toBe('(no new output)\n[status: running]')
})
@@ -175,7 +184,18 @@ describe('task_output', () => {
})
ctx.on('tools/execute', async (exec, next) => {
const taskId = (exec.arguments as { task_id?: unknown }).task_id
if (taskId === 'bash-2') return { content: [{ type: 'text', text: 'a'.repeat(1_000) }], isError: false }
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()
})
@@ -193,7 +213,7 @@ describe('task_output', () => {
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('[result truncated]')
expect(text(shortCircuited)).toContain('[output truncated]')
const failures = [
await call(ctx, 'task_output', { task_id: 'bash-3' }),
@@ -249,7 +269,17 @@ describe('task_list', () => {
p.settle({ status: 'completed', detail: 'exit code: 0' })
await tick()
expect(text(await call(ctx, 'task_list', {}, alice))).toBe([
const listed = await call(ctx, 'task_list', {}, alice)
if (listed.isError) throw new Error('expected task_list success')
const listedValue = listed.value as Array<Record<string, unknown>>
expect(listedValue).toHaveLength(3)
expect(listedValue[0]).toMatchObject({ id: 'bash-1', kind: 'bash', label: 'pnpm test', status: 'running' })
expect(listedValue[2]).toMatchObject({ id: 'bash-2', kind: 'bash', label: 'build', status: 'completed', detail: 'exit code: 0' })
for (const task of listedValue) {
expect(task).not.toHaveProperty('ownerSession')
expect(task).not.toHaveProperty('reported')
}
expect(text(listed)).toBe([
'bash-1 [bash] running — pnpm test',
'subagent-1 [subagent] running — open research',
'bash-2 [bash] completed — build',
@@ -267,6 +297,14 @@ describe('task_kill', () => {
ctx.tasks.start(p.spec)
const result = await call(ctx, 'task_kill', { task_id: 'bash-1', reason: 'superseded' })
if (result.isError) throw new Error('expected task_kill success')
const killValue = result.value as { outcome: string; task: Record<string, unknown> }
expect(killValue).toMatchObject({
outcome: 'cancellation-requested',
task: { id: 'bash-1', kind: 'bash', label: 'sleep 60', status: 'stopping' },
})
expect(killValue.task).not.toHaveProperty('ownerSession')
expect(killValue.task).not.toHaveProperty('reported')
expect(text(result)).toBe('requested cancellation of task bash-1')
expect(p.cancels).toEqual(['superseded'])
})
@@ -346,8 +384,13 @@ describe('task_kill', () => {
p.settle({ status: 'completed', detail: 'exit code: 0' })
await tick()
expect(text(await call(ctx, 'task_kill', { task_id: 'bash-1' })))
.toBe('task bash-1 had already finished [status: completed, exit code: 0]')
const killed = await call(ctx, 'task_kill', { task_id: 'bash-1' })
if (killed.isError) throw new Error('expected task_kill success')
expect(killed.value).toMatchObject({
outcome: 'already-finished',
task: { id: 'bash-1', kind: 'bash', label: 'sleep 60', status: 'completed', detail: 'exit code: 0' },
})
expect(text(killed)).toBe('task bash-1 had already finished [status: completed, exit code: 0]')
// The kill described the task via a non-consuming snapshot: the delta is intact.
expect(text(await call(ctx, 'task_output', { task_id: 'bash-1' }))).toBe('unread tail\n[status: completed, exit code: 0]')
})