Browse Source

feat(tools): canonicalize terminal outputs

Tianyi Cui 2 months ago
parent
commit
e3112be762

+ 2 - 2
.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

+ 1 - 0
.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 }` |

+ 1 - 0
.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 }` |

+ 2 - 0
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.

+ 143 - 15
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<ToolExecutionResult> {
+    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<SendArgs>
@@ -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<string, never>, 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' }),
   }))

+ 48 - 6
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})`

+ 48 - 7
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 () => {