From e3112be7626f371679f47995e95d8d654bcbb113 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 22 Jul 2026 23:23:01 +0800 Subject: [PATCH] feat(tools): canonicalize terminal outputs --- ...0-canonical-tool-output-contract.i18n.yaml | 4 +- ...26-07-20-canonical-tool-output-contract.md | 1 + ...07-20-canonical-tool-output-contract.zh.md | 1 + packages/pty/tool-pty/README.md | 2 + packages/pty/tool-pty/src/index.ts | 158 ++++++++++++++++-- packages/pty/tool-pty/src/render.ts | 54 +++++- packages/pty/tool-pty/tests/tools.spec.ts | 55 +++++- 7 files changed, 245 insertions(+), 30 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.i18n.yaml index 9c50189a08..d106745e70 100644 --- a/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-20-canonical-tool-output-contract.md: c05be75d6a207d9d026b9a75cf50d841292929fe -2026-07-20-canonical-tool-output-contract.zh.md: cf22921c6fd0ec74fbbfd2cfbddd1c4a3379b4f2 +2026-07-20-canonical-tool-output-contract.md: 4099568de5dcc21a89b7873d4a6d4c7e9c62f8e4 +2026-07-20-canonical-tool-output-contract.zh.md: 01b50ef7493ea6548cd238f55e445a702e4d78b3 diff --git a/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md b/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md index c05be75d6a..4099568de5 100644 --- a/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md +++ b/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md @@ -48,6 +48,7 @@ The first-party tools preserve their existing Native text while returning domain | `web_search` / `web_fetch` | The normalized `WebSearchResult` / `WebFetchResult` | | `lsp` | `{ kind: "locations", locations, resolvedWorkspaceRoot }` or `{ kind: "hover", hover }` | | `bash` | `{ kind: "background", taskId }` or `{ kind: "foreground" } & BashRunResult` | +| `terminal_open` / `terminal_list` / `terminal_send` / `terminal_read` / `terminal_signal` / `terminal_close` | Public session snapshots, bounded read/send DTOs, signal/close outcomes, or a background task handle | | `task_output` / `task_list` / `task_kill` | Public task snapshots without owner or notification bookkeeping | | `subagent` | Background task handle or `{ kind: "foreground", runId, output: JsonValue[] }` | | `workflow` / `ralph` | `{ runId, agentsStarted, result: JsonValue }` | diff --git a/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.zh.md b/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.zh.md index cf22921c6f..01b50ef749 100644 --- a/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.zh.md @@ -48,6 +48,7 @@ type ToolExecutionResult = | `web_search` / `web_fetch` | 归一化后的 `WebSearchResult` / `WebFetchResult` | | `lsp` | `{ kind: "locations", locations, resolvedWorkspaceRoot }` 或 `{ kind: "hover", hover }` | | `bash` | `{ kind: "background", taskId }` 或 `{ kind: "foreground" } & BashRunResult` | +| `terminal_open` / `terminal_list` / `terminal_send` / `terminal_read` / `terminal_signal` / `terminal_close` | 公开会话快照、有界的读取/发送 DTO、信号/关闭操作结果,或后台任务句柄 | | `task_output` / `task_list` / `task_kill` | 不含所有者或通知账务字段的公开任务快照 | | `subagent` | 后台任务句柄或 `{ kind: "foreground", runId, output: JsonValue[] }` | | `workflow` / `ralph` | `{ runId, agentsStarted, result: JsonValue }` | diff --git a/packages/pty/tool-pty/README.md b/packages/pty/tool-pty/README.md index 5a0edca33a..eba7c9f2fc 100644 --- a/packages/pty/tool-pty/README.md +++ b/packages/pty/tool-pty/README.md @@ -46,6 +46,8 @@ Prefix-stable while tool visibility and definitions are unchanged. Spawn returns the id and bounded MOTD. Send/read return bounded terminal text plus readiness/history markers. Background mode returns a generic task id. Results remain in session history until compaction; incremental task reads do not repeat consumed output. +Programmatic callers receive typed session snapshots, bounded send/read DTOs, signal and close outcomes, or `{ kind: "background", taskId }`; Native rendering preserves the text above. + #### Token effect Data-dependent and bounded by the backend; each returned result remains in history until compaction. diff --git a/packages/pty/tool-pty/src/index.ts b/packages/pty/tool-pty/src/index.ts index b12db3a277..3d9495d5ae 100644 --- a/packages/pty/tool-pty/src/index.ts +++ b/packages/pty/tool-pty/src/index.ts @@ -6,12 +6,11 @@ import { Context } from 'cordis' import type { Agent } from '@deepseek-ai/dsh-agent' -import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { PtySessionId } from '@deepseek-ai/dsh-pty' import type { PtySendResult, PtySessionId as PtySessionIdType, PtySignal } from '@deepseek-ai/dsh-pty' import type {} from '@deepseek-ai/dsh-tasks' import { defineTool } from '@deepseek-ai/dsh-tools' -import type { ToolExecutionResult, ToolResult } from '@deepseek-ai/dsh-tools' +import type { ToolResult } from '@deepseek-ai/dsh-tools' import { renderList, renderRead, renderSend, renderSendRead, renderSpawn } from './render.ts' declare module '@deepseek-ai/dsh-tasks' { @@ -50,6 +49,41 @@ interface SignalArgs extends SessionArgs { signal: PtySignal } +const SESSION_STATUS_SCHEMA = { + oneOf: [ + { + type: 'object', + additionalProperties: false, + properties: { + kind: { type: 'string', required: true, const: 'running' }, + }, + }, + { + type: 'object', + additionalProperties: false, + properties: { + kind: { type: 'string', required: true, const: 'exited' }, + exitCode: { required: true, oneOf: [{ type: 'integer' }, { type: 'null' }] }, + signal: { required: true, oneOf: [{ type: 'string' }, { type: 'null' }] }, + }, + }, + ], +} as const + +const SESSION_SNAPSHOT_PROPERTIES = { + sessionId: { type: 'string', required: true }, + name: { type: 'string' }, + type: { type: 'string', required: true }, + pid: { type: 'integer' }, + status: { ...SESSION_STATUS_SCHEMA, required: true }, +} as const + +const SESSION_SNAPSHOT_SCHEMA = { + type: 'object', + additionalProperties: false, + properties: SESSION_SNAPSHOT_PROPERTIES, +} as const + function requireAgent(agent: Agent | undefined): Agent { if (agent === undefined) throw new Error('terminal tools require an initiating agent') return agent @@ -62,10 +96,6 @@ function sessionId(args: SessionArgs): PtySessionIdType { return PtySessionId(args.sessionId) } -function textResult(text: string): ContentBlock[] { - return [{ type: 'text', text }] -} - function rawResultText(result: ToolResult): string | undefined { if (result.content.length !== 1) return undefined const block = result.content[0] @@ -94,6 +124,17 @@ export function apply(ctx: Context): void { name: { type: 'string', description: 'Optional owner-local display name such as "main" or "gdb".' }, cwd: { type: 'string', description: 'Initial working directory. Defaults to the deployment workspace root.' }, }, + output: { + schema: { + type: 'object', + additionalProperties: false, + properties: { + ...SESSION_SNAPSHOT_PROPERTIES, + motd: { type: 'string', required: true }, + }, + }, + render: (_args, value) => [{ type: 'text', text: renderSpawn(value) }], + }, async execute(args: SpawnArgs, exec) { if (args.type.length === 0) throw new Error('type must be a non-empty string') const result = await ctx.pty.spawn(requireAgent(exec.agent), { @@ -101,7 +142,7 @@ export function apply(ctx: Context): void { ...args.name !== undefined ? { name: args.name } : {}, ...args.cwd !== undefined ? { cwd: args.cwd } : {}, }, exec.signal) - return textResult(renderSpawn(result)) + return result }, presentCall: (args) => { const parsed = args @@ -118,7 +159,50 @@ export function apply(ctx: Context): void { submit: { type: 'boolean', description: 'Submit Enter after text (default true). Set false for control characters or incomplete REPL input.' }, run_in_background: { type: 'boolean', description: 'Return a task id immediately; collect with task_output or stop with task_kill.' }, }, - async execute(args: SendArgs, exec): Promise { + output: { + schema: { + oneOf: [ + { + type: 'object', + additionalProperties: false, + properties: { + kind: { type: 'string', required: true, const: 'background' }, + taskId: { type: 'string', required: true }, + }, + }, + { + type: 'object', + additionalProperties: false, + properties: { + kind: { type: 'string', required: true, const: 'foreground' }, + viewport: { type: 'string', required: true }, + waitReason: { + type: 'string', + required: true, + enum: ['stdin_read', 'inferred_idle', 'timeout', 'session_exit'], + }, + sessionStatus: { ...SESSION_STATUS_SCHEMA, required: true }, + truncated: { type: 'boolean', required: true }, + }, + }, + ], + }, + render: (_args, value) => [{ + type: 'text', + text: value.kind === 'background' + ? `started background task ${value.taskId}` + : renderSend(value), + }], + presentationMeta: (_args, value) => value.kind === 'foreground' + ? { + viewport: value.viewport, + waitReason: value.waitReason, + sessionStatus: value.sessionStatus, + truncated: value.truncated, + } + : null, + }, + async execute(args: SendArgs, exec) { const owner = requireAgent(exec.agent) const id = sessionId(args) const request = { text: args.text, submit: args.submit ?? true } @@ -145,12 +229,12 @@ export function apply(ctx: Context): void { } }, }) - return { content: textResult(`started background task ${taskId}`), isError: false } + return { kind: 'background' as const, taskId } } const operation = ctx.pty.startSend(owner, id, { ...request, signal: exec.signal }) const result = await operation.done if (exec.signal.aborted) throw new Error('terminal send aborted') - return { content: textResult(renderSend(result)), isError: false, meta: result } + return { kind: 'foreground' as const, ...result } }, presentCall(args) { const parsed = args as Partial @@ -174,12 +258,26 @@ export function apply(ctx: Context): void { offset: { type: 'number', description: 'Newest-relative line offset (default 0).' }, count: { type: 'number', description: 'Requested line count (default 500; backend caps apply).' }, }, + output: { + schema: { + type: 'object', + additionalProperties: false, + properties: { + text: { type: 'string', required: true }, + totalLines: { type: 'integer', required: true }, + lineBegin: { type: 'integer', required: true }, + lineEnd: { type: 'integer', required: true }, + truncated: { type: 'boolean', required: true }, + }, + }, + render: (_args, value) => [{ type: 'text', text: renderRead(value) }], + }, execute(args: ReadArgs, exec) { const result = ctx.pty.read(requireAgent(exec.agent), sessionId(args), { ...args.offset !== undefined ? { offset: args.offset } : {}, ...args.count !== undefined ? { count: args.count } : {}, }) - return Promise.resolve(textResult(renderRead(result))) + return Promise.resolve(result) }, presentCall: args => ({ card: 'generic', title: `Read terminal ${(args).sessionId}`, kind: 'read', rawInput: args }), })) @@ -191,9 +289,19 @@ export function apply(ctx: Context): void { sessionId: { type: 'string', required: true, description: 'Terminal session id.' }, signal: { type: 'string', required: true, enum: ['SIGINT', 'SIGTERM', 'SIGKILL', 'SIGTSTP', 'SIGHUP'], description: 'Signal to deliver. Shell-targeted SIGKILL is rejected; use terminal_close.' }, }, + output: { + schema: { + type: 'object', + additionalProperties: false, + properties: { + delivered: { type: 'boolean', required: true, const: true }, + targetPgid: { type: 'integer', required: true }, + }, + }, + render: (args, value) => [{ type: 'text', text: `delivered ${args.signal} to foreground process group ${value.targetPgid}` }], + }, async execute(args: SignalArgs, exec) { - const result = await ctx.pty.signal(requireAgent(exec.agent), sessionId(args), args.signal) - return textResult(`delivered ${args.signal} to foreground process group ${result.targetPgid}`) + return ctx.pty.signal(requireAgent(exec.agent), sessionId(args), args.signal) }, presentCall: args => ({ card: 'generic', title: `Signal terminal ${args.sessionId}`, kind: 'execute', rawInput: args }), })) @@ -204,10 +312,26 @@ export function apply(ctx: Context): void { parameters: { sessionId: { type: 'string', required: true, description: 'Terminal session id.' }, }, + output: { + schema: { + type: 'object', + additionalProperties: false, + properties: { + sessionId: { type: 'string', required: true }, + outcome: { type: 'string', required: true, enum: ['closed', 'already-closing'] }, + }, + }, + render: (_args, value) => [{ + type: 'text', + text: value.outcome === 'closed' + ? `closed terminal session ${value.sessionId}` + : `terminal session ${value.sessionId} was already closing`, + }], + }, async execute(args: SessionArgs, exec) { const id = sessionId(args) const closed = await ctx.pty.kill(requireAgent(exec.agent), id) - return textResult(closed ? `closed terminal session ${id}` : `terminal session ${id} was already closing`) + return { sessionId: id, outcome: closed ? 'closed' as const : 'already-closing' as const } }, presentCall: args => ({ card: 'generic', title: `Close terminal ${(args).sessionId}`, kind: 'delete' }), })) @@ -216,8 +340,12 @@ export function apply(ctx: Context): void { name: 'terminal_list', description: 'List persistent terminal sessions owned by the current agent.', parameters: {}, + output: { + schema: { type: 'array', items: SESSION_SNAPSHOT_SCHEMA }, + render: (_args, value) => [{ type: 'text', text: renderList(value) }], + }, execute(_args: Record, exec) { - return Promise.resolve(textResult(renderList(ctx.pty.list(requireAgent(exec.agent))))) + return Promise.resolve(ctx.pty.list(requireAgent(exec.agent))) }, presentCall: () => ({ card: 'generic', title: 'List terminal sessions', kind: 'read' }), })) diff --git a/packages/pty/tool-pty/src/render.ts b/packages/pty/tool-pty/src/render.ts index bed176e890..ea1f31bbe0 100644 --- a/packages/pty/tool-pty/src/render.ts +++ b/packages/pty/tool-pty/src/render.ts @@ -1,13 +1,55 @@ /** Model and ACP rendering for persistent terminal tool results. */ -import type { PtyReadResult, PtySendRead, PtySendResult, PtySessionSnapshot, PtySpawnResult } from '@deepseek-ai/dsh-pty' +interface RenderedSessionStatusRunning { + kind: 'running' +} + +interface RenderedSessionStatusExited { + kind: 'exited' + exitCode: number | null + signal: string | null +} + +type RenderedSessionStatus = RenderedSessionStatusRunning | RenderedSessionStatusExited + +interface RenderedSessionSnapshot { + sessionId: string + name?: string + type: string + pid?: number + status: RenderedSessionStatus +} + +interface RenderedSpawnResult extends RenderedSessionSnapshot { + motd: string +} + +interface RenderedSendResult { + viewport: string + waitReason: 'stdin_read' | 'inferred_idle' | 'timeout' | 'session_exit' + sessionStatus: RenderedSessionStatus + truncated: boolean +} + +interface RenderedSendRead { + delta: string + truncated: boolean +} + +interface RenderedReadResult { + text: string + totalLines: number + lineBegin: number + lineEnd: number + truncated: boolean +} /** * Render one created session and its bounded MOTD. * @param result - published spawn result. * @returns Model-facing session acknowledgement. */ -export function renderSpawn(result: PtySpawnResult): string { +export function renderSpawn(result: RenderedSpawnResult): string { const label = result.name === undefined ? result.sessionId : `${result.sessionId} (${result.name})` return `started terminal session ${label} [type: ${result.type}]\n${result.motd || '(no startup output)'}` } @@ -17,7 +59,7 @@ export function renderSpawn(result: PtySpawnResult): string { * @param result - settled send outcome. * @returns Terminal output plus wait/session markers. */ -export function renderSend(result: PtySendResult): string { +export function renderSend(result: RenderedSendResult): string { const output = result.viewport || '(no new output)' const status = result.sessionStatus.kind === 'running' ? 'running' @@ -30,7 +72,7 @@ export function renderSend(result: PtySendResult): string { * @param read - consuming operation delta. * @returns Delta plus truncation marker when needed. */ -export function renderSendRead(read: PtySendRead): string { +export function renderSendRead(read: RenderedSendRead): string { return `${read.delta}${read.truncated ? `${read.delta.endsWith('\n') || read.delta.length === 0 ? '' : '\n'}[output truncated]` : ''}` } @@ -39,7 +81,7 @@ export function renderSendRead(read: PtySendRead): string { * @param result - retained scrollback page. * @returns Page text plus pagination and truncation markers. */ -export function renderRead(result: PtyReadResult): string { +export function renderRead(result: RenderedReadResult): string { const output = result.text || '(no retained output)' return `${output}\n[lines: ${result.lineBegin}-${result.lineEnd} of ${result.totalLines}]${result.truncated ? '\n[output truncated]' : ''}` } @@ -49,7 +91,7 @@ export function renderRead(result: PtyReadResult): string { * @param sessions - fresh owner-scoped snapshots. * @returns One line per session or the empty marker. */ -export function renderList(sessions: PtySessionSnapshot[]): string { +export function renderList(sessions: readonly RenderedSessionSnapshot[]): string { if (sessions.length === 0) return '(no terminal sessions)' return sessions.map((session) => { const name = session.name === undefined ? '' : ` (${session.name})` diff --git a/packages/pty/tool-pty/tests/tools.spec.ts b/packages/pty/tool-pty/tests/tools.spec.ts index 5adcaea441..b054a7be90 100644 --- a/packages/pty/tool-pty/tests/tools.spec.ts +++ b/packages/pty/tool-pty/tests/tools.spec.ts @@ -124,13 +124,50 @@ describe('tool-pty foreground surface', () => { const spawned = await call(ctx, 'terminal_open', { type: 'stub', name: 'main' }, agent) expect(text(spawned)).toContain('started terminal session pty-1 (main)') - expect(text(await call(ctx, 'terminal_list', {}, agent))).toContain('pty-1 (main) [stub] running pid=42') - expect(text(await call(ctx, 'terminal_read', { sessionId: 'pty-1' }, agent))).toContain('history\n[lines: 0-1 of 1]') - expect(text(await call(ctx, 'terminal_signal', { sessionId: 'pty-1', signal: 'SIGINT' }, agent))).toBe('delivered SIGINT to foreground process group 10') + expect(spawned).toMatchObject({ + isError: false, + value: { + sessionId: 'pty-1', + name: 'main', + type: 'stub', + pid: 42, + status: { kind: 'running' }, + motd: 'stub prompt', + }, + }) + const listed = await call(ctx, 'terminal_list', {}, agent) + expect(text(listed)).toContain('pty-1 (main) [stub] running pid=42') + expect(listed).toMatchObject({ isError: false, value: [{ sessionId: 'pty-1', name: 'main', type: 'stub', pid: 42, status: { kind: 'running' } }] }) + const read = await call(ctx, 'terminal_read', { sessionId: 'pty-1' }, agent) + expect(text(read)).toContain('history\n[lines: 0-1 of 1]') + expect(read).toMatchObject({ isError: false, value: { text: 'history', totalLines: 1, lineBegin: 0, lineEnd: 1, truncated: false } }) + const signalled = await call(ctx, 'terminal_signal', { sessionId: 'pty-1', signal: 'SIGINT' }, agent) + expect(text(signalled)).toBe('delivered SIGINT to foreground process group 10') + expect(signalled).toMatchObject({ isError: false, value: { delivered: true, targetPgid: 10 } }) const sent = await call(ctx, 'terminal_send', { sessionId: 'pty-1', text: 'echo hi' }, agent) expect(text(sent)).toContain('command output\n[wait: stdin_read]\n[session: running]') - expect(text(await call(ctx, 'terminal_close', { sessionId: 'pty-1' }, agent))).toBe('closed terminal session pty-1') - expect(text(await call(ctx, 'terminal_list', {}, agent))).toBe('(no terminal sessions)') + expect(sent).toMatchObject({ + isError: false, + value: { + kind: 'foreground', + viewport: 'command output', + waitReason: 'stdin_read', + sessionStatus: { kind: 'running' }, + truncated: false, + }, + meta: { + viewport: 'command output', + waitReason: 'stdin_read', + sessionStatus: { kind: 'running' }, + truncated: false, + }, + }) + const closed = await call(ctx, 'terminal_close', { sessionId: 'pty-1' }, agent) + expect(text(closed)).toBe('closed terminal session pty-1') + expect(closed).toMatchObject({ isError: false, value: { sessionId: 'pty-1', outcome: 'closed' } }) + const empty = await call(ctx, 'terminal_list', {}, agent) + expect(text(empty)).toBe('(no terminal sessions)') + expect(empty).toMatchObject({ isError: false, value: [] }) }) it('fails without an initiating agent and rejects background before writing', async () => { @@ -178,7 +215,9 @@ describe('tool-pty task integration', () => { it('registers a generic task and exposes incremental output', async () => { const { ctx, agent } = await setup(true) await call(ctx, 'terminal_open', { type: 'stub' }, agent) - expect(text(await call(ctx, 'terminal_send', { sessionId: 'pty-1', text: 'build', run_in_background: true }, agent))).toBe('started background task pty-send-1') + const started = await call(ctx, 'terminal_send', { sessionId: 'pty-1', text: 'build', run_in_background: true }, agent) + expect(text(started)).toBe('started background task pty-send-1') + expect(started).toMatchObject({ isError: false, value: { kind: 'background', taskId: 'pty-send-1' } }) const output = await call(ctx, 'task_output', { task_id: 'pty-send-1', wait: true }, agent) expect(text(output)).toContain('live output') expect(text(output)).toContain('[status: completed, wait: stdin_read]') @@ -224,7 +263,9 @@ describe('tool-pty task integration', () => { const second = call(ctx, 'terminal_close', { sessionId: 'pty-1' }, agent) stub.sessions[0]!.closeGate?.resolve(undefined) await first - expect(text(await second)).toBe('terminal session pty-1 was already closing') + const result = await second + expect(text(result)).toBe('terminal session pty-1 was already closing') + expect(result).toMatchObject({ isError: false, value: { sessionId: 'pty-1', outcome: 'already-closing' } }) }) it('renders an exited session detail for background completion', async () => {