fix(pty): keep bounded results actionable

This commit is contained in:
Tianyi Cui
2026-07-23 00:48:00 +08:00
parent 5ba4f77f2b
commit be20804684
8 changed files with 55 additions and 12 deletions

View File

@@ -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-16-persistent-pty-sessions.md: db161a865f1c30fc3979e1a92dc6d427ecb58f25
2026-07-16-persistent-pty-sessions.zh.md: 06c902437aee2e5725e1765840dce03ae15c30a4
2026-07-16-persistent-pty-sessions.md: 689019d56d719884761407f288e1e765dd19c35d
2026-07-16-persistent-pty-sessions.zh.md: 14490137a003e2ca67b594628e506f0b74e3d4b7

View File

@@ -62,7 +62,7 @@ The ACP render contract is exact and location-free. `terminal_send` uses termina
`terminal_send({ sessionId, text, submit?, run_in_background? })` treats `text` as UTF-8 bytes and resolves `submit` to `true` in the tool implementation. When `submit` is true it writes the platform Enter sequence after the text; when false it writes only the text, allowing control characters and REPL fragments without hidden content heuristics. `enableRunInBackground` defaults to true; false removes `run_in_background` from the schema and rejects the same undeclared argument if a caller forces it through execution.
Foreground sends return a bounded rendered delta and two independent facts: `waitReason` (`stdin_read | inferred_idle | timeout | session_exit`) and `sessionStatus` (`running` or `exited` with exit code or signal). `session_exit` refers to the PTY's top-level shell process, not an arbitrary foreground command whose status the shell consumes. A timeout never implies process exit. `dsh-tool-pty.maxResultBytes` defaults to 262144 and caps the complete UTF-8 result after wait, session, pagination, truncation, and generic task-status wrappers; the renderer reserves suffix space and preserves code-point boundaries instead of treating the backend payload cap as the final model bound.
Foreground sends return a bounded rendered delta and two independent facts: `waitReason` (`stdin_read | inferred_idle | timeout | session_exit`) and `sessionStatus` (`running` or `exited` with exit code or signal). `session_exit` refers to the PTY's top-level shell process, not an arbitrary foreground command whose status the shell consumes. A timeout never implies process exit. `dsh-tool-pty.maxResultBytes` defaults to 262144, rejects values below 64 so creation acknowledgements retain registry-issued ids, and caps the complete UTF-8 result after normalized errors, wait, session, pagination, truncation, and generic task-status wrappers; the renderer reserves suffix space and preserves code-point boundaries instead of treating the backend payload cap as the final model bound.
With `run_in_background: true`, `dsh-tool-pty` registers the in-flight send on `ctx.tasks` and returns immediately with `taskId`. The producer places `maxResultBytes` on the task snapshot so `task_output`, terminal kill status, and completion notices enforce the same complete-result cap after generic metadata. `task_output(wait: true)` waits, reads incremental output, and records the final result; `task_kill` resolves the current foreground PGID and delivers a real `SIGINT`, including when the application has disabled terminal `ISIG`, and escalates only through the PTY backend's owned teardown path. If the task surface is absent, background mode fails before writing input. No PTY-specific `sleep` tool or general wake-up seam is added.

View File

@@ -62,7 +62,7 @@ ACP 渲染契约精确且不携带位置信息。`terminal_send` 只为前台发
`terminal_send({ sessionId, text, submit?, run_in_background? })``text` 视为 UTF-8 字节,并由工具实现在解析阶段把 `submit` 默认成 `true``submit` 为 true 时先写入文本,再写入平台 Enter 序列;为 false 时只写文本,使控制字符和 REPL 片段无需隐藏的内容启发式即可发送。`enableRunInBackground` 默认为 true设为 false 时schema 中会移除 `run_in_background`,调用方即使强行把这个未声明参数传入执行流程,也会被拒绝。
前台发送返回有界的渲染增量和两个独立事实:`waitReason``stdin_read | inferred_idle | timeout | session_exit`)与 `sessionStatus``running`,或携带退出码或信号的 `exited`)。`session_exit` 指 PTY 顶层 shell 进程退出,不指由 shell 消费状态的任意前台命令。timeout 从不意味着进程已经退出。`dsh-tool-pty.maxResultBytes` 默认为 262144完整 UTF-8 结果在加入等待与会话状态、分页与截断元数据以及通用 task 状态包装后,仍受该值限制。渲染器会为后缀预留空间并保持代码点边界,而不会把后端载荷上限当作面向模型结果的最终上限。
前台发送返回有界的渲染增量和两个独立事实:`waitReason``stdin_read | inferred_idle | timeout | session_exit`)与 `sessionStatus``running`,或携带退出码或信号的 `exited`)。`session_exit` 指 PTY 顶层 shell 进程退出,不指由 shell 消费状态的任意前台命令。timeout 从不意味着进程已经退出。`dsh-tool-pty.maxResultBytes` 默认为 262144低于 64 的值会被拒绝,以确保创建确认保留 registry 签发的 id完整 UTF-8 结果在加入规范化错误、等待与会话状态、分页与截断元数据以及通用 task 状态包装后,仍受该值限制。渲染器会为后缀预留空间并保持代码点边界,而不会把后端载荷上限当作面向模型结果的最终上限。
`run_in_background: true` 时,`dsh-tool-pty``ctx.tasks` 上注册进行中的发送,并立即返回 `taskId`。生产方把 `maxResultBytes` 写入 task 快照,使 `task_output`、kill 返回的终态状态和完成通知在加上通用元数据后,仍对完整结果执行同一上限。`task_output(wait: true)` 负责等待、读取增量输出并记录最终结果;`task_kill` 会解析当前前台 PGID 并发送真正的 `SIGINT`,即使应用已禁用终端 `ISIG` 也同样如此,且后续升级仍只通过 PTY 后端拥有的 teardown 路径进行。若 task 对外接口不存在,后台模式必须在写入输入前失败。设计不新增 PTY 专用的 `sleep` 工具或通用唤醒 seam。

View File

@@ -1383,7 +1383,7 @@ export interface Config {
}
```
Source: [`packages/pty/tool-pty/src/index.ts:33`](../packages/pty/tool-pty/src/index.ts)
Source: [`packages/pty/tool-pty/src/index.ts:44`](../packages/pty/tool-pty/src/index.ts)
## `@deepseek-ai/dsh-tool-ralph`

View File

@@ -42,7 +42,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:29`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) |
| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:35`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:123`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - |
| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:93`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`timeout-policy`](../packages/timeout/timeout-policy) |
| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:93`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`timeout-policy`](../packages/timeout/timeout-policy), [`tool-pty`](../packages/pty/tool-pty) |
| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:105`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`workspace-context`](../packages/context/workspace-context) |
| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:82`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:113`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) |

View File

@@ -9,9 +9,9 @@ Six model-facing tools over `ctx.pty`: `terminal_open`, `terminal_send`, `termin
| key | default | meaning |
|---|---:|---|
| `enableRunInBackground` | `true` | expose and accept `run_in_background`; false omits the schema field and rejects a forced undeclared argument |
| `maxResultBytes` | `262144` | UTF-8 cap for each complete terminal result or PTY task output after wait, session, pagination, truncation, and task-status metadata |
| `maxResultBytes` | `262144` | UTF-8 cap (minimum `64`) for each complete terminal result or PTY task output after wait, session, pagination, truncation, and task-status metadata |
Both values are validated at load. When a result exceeds `maxResultBytes`, rendering reserves space for control metadata and a truncation marker when they fit; cuts preserve UTF-8 boundaries.
Both values are validated at load. The minimum result cap keeps every registry-issued session or task id visible in its creation acknowledgement. When a result exceeds `maxResultBytes`, rendering reserves space for control metadata and a truncation marker when they fit; cuts preserve UTF-8 boundaries.
## Model Experience
@@ -53,7 +53,7 @@ Prefix-stable while tool visibility and definitions are unchanged.
#### What the model sees
Spawn returns the id and bounded MOTD. Send/read return bounded terminal text plus readiness/history markers. Background mode returns a generic task id. Every complete result is capped by `maxResultBytes`, including generic task status text. Results remain in session history until compaction; incremental task reads do not repeat consumed output.
Spawn returns the id and bounded MOTD. Send/read return bounded terminal text plus readiness/history markers. Background mode returns a generic task id. Every complete result is capped by `maxResultBytes`, including normalized error text and generic task status text. Results remain in session history until compaction; incremental task reads do not repeat consumed output.
#### Token effect

View File

@@ -28,6 +28,17 @@ export const inject = ['pty', 'tools', 'systemPrompt']
/** Default cap for one complete model-facing terminal result. */
export const DEFAULT_MAX_RESULT_BYTES = 256 * 1024
/** Smallest cap that preserves every counter-backed PTY and task id in its creation acknowledgement. */
export const MIN_MAX_RESULT_BYTES = 64
const TOOL_NAMES = new Set([
'terminal_open',
'terminal_send',
'terminal_read',
'terminal_signal',
'terminal_close',
'terminal_list',
])
/** Model-facing terminal tool configuration. */
export interface Config {
@@ -40,7 +51,7 @@ export interface Config {
/** 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),
maxResultBytes: z.number().step(1).min(MIN_MAX_RESULT_BYTES).max(Number.MAX_SAFE_INTEGER).default(DEFAULT_MAX_RESULT_BYTES),
})
interface SpawnArgs {
@@ -100,9 +111,15 @@ function sendDetail(result: PtySendResult): string {
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')
if (!Number.isSafeInteger(maxResultBytes) || maxResultBytes < MIN_MAX_RESULT_BYTES) {
throw new Error(`tool-pty: maxResultBytes must be a safe integer of at least ${MIN_MAX_RESULT_BYTES}`)
}
ctx.on('tools/execute', async (exec, next): Promise<ToolExecutionResult> => {
const result = await next()
if (!TOOL_NAMES.has(exec.name)) return result
const raw = rawResultText(result)
return raw === undefined ? result : { ...result, content: textResult(raw, maxResultBytes) }
})
ctx.systemPrompt.section({
name: 'tool:pty',
order: 106,

View File

@@ -197,6 +197,32 @@ describe('tool-pty foreground surface', () => {
const invalid = await setupBase(false)
expect(() => { ToolPty.apply(invalid.ctx, { maxResultBytes: 0 }) }).toThrow('maxResultBytes')
expect(() => { ToolPty.apply(invalid.ctx, { maxResultBytes: 63 }) }).toThrow('at least 64')
})
it('bounds normalized errors and preserves allocated ids at the minimum result cap', async () => {
const { ctx, agent } = await setup(true, { maxResultBytes: 64 })
const failed = await call(ctx, 'terminal_open', { type: 'x'.repeat(1_000) }, agent)
expect(failed.isError).toBe(true)
expect(Buffer.byteLength(text(failed))).toBeLessThanOrEqual(64)
expect(text(failed)).toContain('[output truncated]')
const opened = await call(ctx, 'terminal_open', { type: 'stub', name: 'n'.repeat(1_000) }, agent)
expect(text(opened)).toContain('pty-1')
expect(Buffer.byteLength(text(opened))).toBeLessThanOrEqual(64)
const background = await call(ctx, 'terminal_send', {
sessionId: 'pty-1', text: 'work', run_in_background: true,
}, agent)
expect(text(background)).toContain('pty-send-1')
expect(Buffer.byteLength(text(background))).toBeLessThanOrEqual(64)
})
it('leaves a structured around-dispatch replacement unchanged', async () => {
const { ctx, agent } = await setup(false, { maxResultBytes: 64 })
ctx.on('tools/execute', async (exec, next) => exec.name === 'terminal_list'
? { content: [], isError: false }
: next())
expect((await call(ctx, 'terminal_list', {}, agent)).content).toEqual([])
})
})