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

@@ -5,6 +5,7 @@
*/
import { Context } from 'cordis'
import z from 'schemastery'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { PtySessionId } from '@deepseek-ai/dsh-pty'
@@ -12,7 +13,7 @@ import type { PtySendResult, PtySessionId as PtySessionIdType, PtySignal } from
import type {} from '@deepseek-ai/dsh-tasks'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { ToolExecutionResult, ToolResult } from '@deepseek-ai/dsh-tools'
import { renderList, renderRead, renderSend, renderSendRead, renderSpawn } from './render.ts'
import { boundTerminalText, renderList, renderRead, renderSend, renderSendRead, renderSpawn } from './render.ts'
declare module '@deepseek-ai/dsh-tasks' {
interface TaskKindMap {
@@ -25,6 +26,23 @@ export const name = 'tool-pty'
/** Required capability, registry, and prompt services. */
export const inject = ['pty', 'tools', 'systemPrompt']
/** Default cap for one complete model-facing terminal result. */
export const DEFAULT_MAX_RESULT_BYTES = 256 * 1024
/** Model-facing terminal tool configuration. */
export interface Config {
/** Expose `run_in_background` and accept background sends (default true). */
enableRunInBackground?: boolean
/** Maximum UTF-8 bytes in one complete terminal or task-output result. */
maxResultBytes?: number
}
/** Schemastery configuration for the terminal tool consumer. */
export const Config: z<Config> = z.object({
enableRunInBackground: z.boolean().default(true),
maxResultBytes: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER).default(DEFAULT_MAX_RESULT_BYTES),
})
interface SpawnArgs {
type: string
name?: string
@@ -62,8 +80,8 @@ function sessionId(args: SessionArgs): PtySessionIdType {
return PtySessionId(args.sessionId)
}
function textResult(text: string): ContentBlock[] {
return [{ type: 'text', text }]
function textResult(text: string, maxBytes: number): ContentBlock[] {
return [{ type: 'text', text: boundTerminalText(text, maxBytes) }]
}
function rawResultText(result: ToolResult): string | undefined {
@@ -79,7 +97,12 @@ function sendDetail(result: PtySendResult): string {
}
/** Register all terminal tools and the minimal usage guidance. */
export function apply(ctx: Context): void {
export function apply(ctx: Context, config: Config = {}): void {
const enableRunInBackground = config.enableRunInBackground ?? true
const maxResultBytes = config.maxResultBytes ?? DEFAULT_MAX_RESULT_BYTES
if (!Number.isSafeInteger(maxResultBytes) || maxResultBytes <= 0) {
throw new Error('tool-pty: maxResultBytes must be a positive safe integer')
}
ctx.systemPrompt.section({
name: 'tool:pty',
order: 106,
@@ -101,7 +124,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 textResult(renderSpawn(result, maxResultBytes), maxResultBytes)
},
presentCall: (args) => {
const parsed = args
@@ -111,18 +134,22 @@ export function apply(ctx: Context): void {
ctx.tools.register(defineTool({
name: 'terminal_send',
description: 'Send text to a persistent terminal. By default Enter is submitted and the call waits for a prompt, stdin wait, output silence, timeout, or session exit. Background mode returns a task id for task_output/task_kill.',
description: 'Send text to a persistent terminal. By default Enter is submitted and the call waits for a prompt, stdin wait, output silence, timeout, or session exit.'
+ (enableRunInBackground ? ' Background mode returns a task id for task_output/task_kill.' : ''),
parameters: {
sessionId: { type: 'string', required: true, description: 'Terminal session id returned by terminal_open or terminal_list.' },
text: { type: 'string', required: true, description: 'UTF-8 text to write to the terminal.' },
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.' },
...enableRunInBackground
? { run_in_background: { type: 'boolean' as const, description: 'Return a task id immediately; collect with task_output or stop with task_kill.' } }
: {},
},
async execute(args: SendArgs, exec): Promise<ToolExecutionResult> {
const owner = requireAgent(exec.agent)
const id = sessionId(args)
const request = { text: args.text, submit: args.submit ?? true }
if (args.run_in_background === true) {
if (!enableRunInBackground) throw new Error('background terminal sends are disabled by tool-pty configuration')
const tasks = ctx.get('tasks')
if (tasks === undefined) throw new Error('background terminal sends require @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks')
let cancelRequested = false
@@ -130,6 +157,7 @@ export function apply(ctx: Context): void {
kind: 'pty-send',
label: `${id}: ${args.text || '(input)'}`,
owner,
outputLimitBytes: maxResultBytes,
run: () => {
const operation = ctx.pty.startSend(owner, id, request)
return {
@@ -145,12 +173,12 @@ export function apply(ctx: Context): void {
}
},
})
return { content: textResult(`started background task ${taskId}`), isError: false }
return { content: textResult(`started background task ${taskId}`, maxResultBytes), isError: false }
}
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 { content: textResult(renderSend(result, maxResultBytes), maxResultBytes), isError: false, meta: result }
},
presentCall(args) {
const parsed = args as Partial<SendArgs>
@@ -179,7 +207,7 @@ export function apply(ctx: Context): void {
...args.offset !== undefined ? { offset: args.offset } : {},
...args.count !== undefined ? { count: args.count } : {},
})
return Promise.resolve(textResult(renderRead(result)))
return Promise.resolve(textResult(renderRead(result, maxResultBytes), maxResultBytes))
},
presentCall: args => ({ card: 'generic', title: `Read terminal ${(args).sessionId}`, kind: 'read', rawInput: args }),
}))
@@ -193,7 +221,7 @@ export function apply(ctx: Context): void {
},
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 textResult(`delivered ${args.signal} to foreground process group ${result.targetPgid}`, maxResultBytes)
},
presentCall: args => ({ card: 'generic', title: `Signal terminal ${(args as SignalArgs).sessionId}`, kind: 'execute', rawInput: args }),
}))
@@ -207,7 +235,7 @@ export function apply(ctx: Context): void {
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 textResult(closed ? `closed terminal session ${id}` : `terminal session ${id} was already closing`, maxResultBytes)
},
presentCall: args => ({ card: 'generic', title: `Close terminal ${(args).sessionId}`, kind: 'delete' }),
}))
@@ -217,7 +245,7 @@ export function apply(ctx: Context): void {
description: 'List persistent terminal sessions owned by the current agent.',
parameters: {},
execute(_args: Record<string, never>, exec) {
return Promise.resolve(textResult(renderList(ctx.pty.list(requireAgent(exec.agent)))))
return Promise.resolve(textResult(renderList(ctx.pty.list(requireAgent(exec.agent)), maxResultBytes), maxResultBytes))
},
presentCall: () => ({ card: 'generic', title: 'List terminal sessions', kind: 'read' }),
}))

View File

@@ -1,57 +1,128 @@
/** Model and ACP rendering for persistent terminal tool results. */
import { TextRetainer } from '@deepseek-ai/dsh-retention'
import type { PtyReadResult, PtySendRead, PtySendResult, PtySessionSnapshot, PtySpawnResult } from '@deepseek-ai/dsh-pty'
const encoder = new TextEncoder()
const TRUNCATED = '\n[output truncated]'
function byteLength(text: string): number {
return encoder.encode(text).byteLength
}
function retain(text: string, maxBytes: number, kind: 'head' | 'tail'): string {
const retainer = new TextRetainer({ kind, maxBytes })
retainer.push(text)
return retainer.finish().text
}
function fitWithSuffix(content: string, suffix: string, maxBytes: number): string {
const fixedBytes = byteLength(suffix)
if (fixedBytes >= maxBytes) return retain(suffix, maxBytes, 'tail')
return `${retain(content, maxBytes - fixedBytes, 'tail')}${suffix}`
}
function fitWithPrefix(prefix: string, content: string, maxBytes: number): string {
const fixed = `${prefix}${TRUNCATED}`
const fixedBytes = byteLength(fixed)
if (fixedBytes >= maxBytes) return retain(fixed, maxBytes, 'head')
return `${prefix}${retain(content, maxBytes - fixedBytes, 'tail')}${TRUNCATED}`
}
function boundBodyWithSuffix(
content: string,
metadata: string,
upstreamTruncated: boolean,
maxBytes: number,
): string {
const suffix = `${metadata}${upstreamTruncated ? TRUNCATED : ''}`
const complete = `${content}${suffix}`
if (byteLength(complete) <= maxBytes) return complete
return fitWithSuffix(content, `${metadata}${TRUNCATED}`, maxBytes)
}
/**
* Bound one complete terminal acknowledgement while preserving UTF-8 cuts.
* @param text - complete acknowledgement text.
* @param maxBytes - positive final result cap.
* @returns bounded text with a truncation marker when it fits.
*/
export function boundTerminalText(text: string, maxBytes: number): string {
if (byteLength(text) <= maxBytes) return text
const markerBytes = byteLength(TRUNCATED)
if (markerBytes >= maxBytes) return retain(TRUNCATED, maxBytes, 'tail')
return `${retain(text, maxBytes - markerBytes, 'head')}${TRUNCATED}`
}
/**
* Render one created session and its bounded MOTD.
* @param result - published spawn result.
* @param maxBytes - complete UTF-8 result cap.
* @returns Model-facing session acknowledgement.
*/
export function renderSpawn(result: PtySpawnResult): string {
export function renderSpawn(result: PtySpawnResult, maxBytes: number): 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)'}`
const prefix = `started terminal session ${label} [type: ${result.type}]\n`
const motd = result.motd || '(no startup output)'
const complete = `${prefix}${motd}`
return byteLength(complete) <= maxBytes ? complete : fitWithPrefix(prefix, motd, maxBytes)
}
/**
* Render one settled interactive send.
* @param result - settled send outcome.
* @param maxBytes - complete UTF-8 result cap.
* @returns Terminal output plus wait/session markers.
*/
export function renderSend(result: PtySendResult): string {
export function renderSend(result: PtySendResult, maxBytes: number): string {
const output = result.viewport || '(no new output)'
const status = result.sessionStatus.kind === 'running'
? 'running'
: `exited code=${result.sessionStatus.exitCode ?? 'null'} signal=${result.sessionStatus.signal ?? 'null'}`
return `${output}\n[wait: ${result.waitReason}]\n[session: ${status}]${result.truncated ? '\n[output truncated]' : ''}`
return boundBodyWithSuffix(
output,
`\n[wait: ${result.waitReason}]\n[session: ${status}]`,
result.truncated,
maxBytes,
)
}
/**
* Render one incremental background operation read.
* @param read - consuming operation delta.
* @returns Delta plus truncation marker when needed.
* @returns Delta plus its upstream truncation marker. The generic task control
* applies the producer's complete-result cap after adding task status.
*/
export function renderSendRead(read: PtySendRead): string {
return `${read.delta}${read.truncated ? `${read.delta.endsWith('\n') || read.delta.length === 0 ? '' : '\n'}[output truncated]` : ''}`
const separator = read.delta.endsWith('\n') || read.delta.length === 0 ? '' : '\n'
return `${read.delta}${read.truncated ? `${separator}[output truncated]` : ''}`
}
/**
* Render one bounded historical page.
* @param result - retained scrollback page.
* @param maxBytes - complete UTF-8 result cap.
* @returns Page text plus pagination and truncation markers.
*/
export function renderRead(result: PtyReadResult): string {
export function renderRead(result: PtyReadResult, maxBytes: number): string {
const output = result.text || '(no retained output)'
return `${output}\n[lines: ${result.lineBegin}-${result.lineEnd} of ${result.totalLines}]${result.truncated ? '\n[output truncated]' : ''}`
return boundBodyWithSuffix(
output,
`\n[lines: ${result.lineBegin}-${result.lineEnd} of ${result.totalLines}]`,
result.truncated,
maxBytes,
)
}
/**
* Render owner-visible live sessions.
* @param sessions - fresh owner-scoped snapshots.
* @param maxBytes - complete UTF-8 result cap.
* @returns One line per session or the empty marker.
*/
export function renderList(sessions: PtySessionSnapshot[]): string {
export function renderList(sessions: PtySessionSnapshot[], maxBytes: number): string {
if (sessions.length === 0) return '(no terminal sessions)'
return sessions.map((session) => {
const text = sessions.map((session) => {
const name = session.name === undefined ? '' : ` (${session.name})`
const pid = session.pid === undefined ? '' : ` pid=${session.pid}`
const status = session.status.kind === 'running'
@@ -59,4 +130,5 @@ export function renderList(sessions: PtySessionSnapshot[]): string {
: `exited code=${session.status.exitCode ?? 'null'} signal=${session.status.signal ?? 'null'}`
return `${session.sessionId}${name} [${session.type}] ${status}${pid}`
}).join('\n')
return boundBodyWithSuffix(text, '', false, maxBytes)
}