From 6ad8e4501f601d95a9b7a8df71e8c2f958cdeb3e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 23 Jul 2026 01:21:14 +0800 Subject: [PATCH] fix(pty): retain cleanup evidence through policy --- ...26-07-16-persistent-pty-sessions.i18n.yaml | 4 +-- .../2026-07-16-persistent-pty-sessions.md | 4 +-- .../2026-07-16-persistent-pty-sessions.zh.md | 4 +-- docs/cordis-catalog/services.md | 2 +- docs/event-producer-consumer.md | 4 +-- packages/pty/pty/README.md | 1 + packages/pty/pty/src/index.ts | 14 ++++++-- packages/pty/pty/tests/service.spec.ts | 35 +++++++++++++++++++ packages/pty/tool-pty/README.md | 6 ++-- packages/pty/tool-pty/src/index.ts | 27 ++++++++------ packages/pty/tool-pty/tests/tools.spec.ts | 31 ++++++++++++++++ 11 files changed, 106 insertions(+), 26 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml index 426b1180b0..f772b6d5e6 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.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-16-persistent-pty-sessions.md: ba8d8579c107f89f83b2a9ab40298ac876df4521 -2026-07-16-persistent-pty-sessions.zh.md: 44e66094b905560e0cd5f3c30204e46e93351791 +2026-07-16-persistent-pty-sessions.md: 8a9ec669a064924ecdb9693fff5c4cafea59d90c +2026-07-16-persistent-pty-sessions.zh.md: 157bee408d041b5c373dd2a4d41f82de90a67e0e diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md index ba8d8579c1..8a9ec669a0 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md @@ -34,7 +34,7 @@ Idle detection is backend behavior, not a second public seam. A remote or contai There are no plugin-load auto-start sessions. `terminal_open` creates a session only during an agent tool call, when ownership and the owning event-sourced session are known. A future declarative startup feature must compose through unpublished agent setup rather than create shared global terminals. -Agent-scope disposal closes registrations first, then awaits quiescent teardown of every owned PTY. Unpublished backend setup is a tracked lifecycle operation: owner or service disposal aborts its service-owned signal, waits for backend settlement and rollback, and only then returns. Caller cancellation retains its exact `AbortSignal.reason` even when the backend rejects in response; a service rollback close failure rejects both the spawn and the disposing lifecycle, while `PtyBackendCleanupError` lets a backend preserve its own failed startup cleanup for the disposing lifecycle without replacing that caller reason. Backend or tool-plugin reload does not orphan sessions: ownership lives in `PtyService` until the agent ends, following the same service-owned-record pattern as [`ctx.tasks`](../../../../packages/tasks/tasks/README.md). The service reserves the session synchronously for one active send before returning its operation, including before a background task id becomes visible; a second send fails with `SEND_ACTIVE`, so output and cancellation cannot cross operation ownership. +Agent-scope disposal closes registrations first, then awaits quiescent teardown of every owned PTY. Unpublished backend setup is a tracked lifecycle operation: owner or service disposal aborts its service-owned signal, waits for backend settlement and rollback, and only then returns. Caller cancellation retains its exact `AbortSignal.reason` even when the backend rejects in response; a service rollback close failure rejects both the spawn and the disposing lifecycle, while `PtyBackendCleanupError` lets a backend preserve its own failed startup cleanup for the disposing lifecycle without replacing that caller reason. When caller cancellation settles before disposal, the cleanup failure remains tracked owner activity until later owner or service disposal consumes and reports it, so sandbox-mode policy cannot mistake failed cleanup for quiescence. Backend or tool-plugin reload does not orphan sessions: ownership lives in `PtyService` until the agent ends, following the same service-owned-record pattern as [`ctx.tasks`](../../../../packages/tasks/tasks/README.md). The service reserves the session synchronously for one active send before returning its operation, including before a background task id becomes visible; a second send fails with `SEND_ACTIVE`, so output and cancellation cannot cross operation ownership. ### Security and process boundary @@ -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, 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. +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 each single-text UTF-8 result after normalized errors, wait, session, pagination, truncation, generic task-status wrappers, pre-execute denials, and post-execute replacements or blocks; its outer post-execute wrapper leaves deliberately structured multi-block policy content unchanged. 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. diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md index 44e66094b9..157bee408d 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md @@ -34,7 +34,7 @@ idle 检测属于后端行为,不是第二条公共 seam。远程或容器后 实现不提供插件加载期 auto-start 会话。`terminal_open` 只在 agent 工具调用期间创建会话,此时所有权和所属的事件溯源会话都已确定。未来的声明式启动功能必须通过尚未发布的 agent setup 组合,而不能创建全局共享终端。 -agent scope dispose 时先关闭注册,再等待全部所属 PTY 静默退出。未发布的后端 setup 同样是受追踪的生命周期操作:owner 或服务 dispose 会中止服务自有的 signal,等待后端结算与回滚完成后才返回。即使后端响应取消而 reject,调用方取消仍原样保留其 `AbortSignal.reason`;服务侧回滚 close 失败会使 spawn 与正在执行的 lifecycle dispose 都 reject,而 `PtyBackendCleanupError` 让后端在不替换该调用方原因的前提下,为正在执行的 dispose 保留自身的启动清理失败。后端或工具插件 reload 不会遗留会话:所有权持续存放在 `PtyService` 中,直到 agent 结束,与 [`ctx.tasks`](../../../../packages/tasks/tasks/README.md) 的服务持有记录模式一致。服务会先同步把会话预留给一次活跃发送,再返回该操作;后台发送同样会在 task id 对外可见前完成预留。第二次发送会以 `SEND_ACTIVE` 失败,因此输出与取消无法跨越操作所有权。 +agent scope dispose 时先关闭注册,再等待全部所属 PTY 静默退出。未发布的后端 setup 同样是受追踪的生命周期操作:owner 或服务 dispose 会中止服务自有的 signal,等待后端结算与回滚完成后才返回。即使后端响应取消而 reject,调用方取消仍原样保留其 `AbortSignal.reason`;服务侧回滚 close 失败会使 spawn 与正在执行的 lifecycle dispose 都 reject,而 `PtyBackendCleanupError` 让后端在不替换该调用方原因的前提下,为正在执行的 dispose 保留自身的启动清理失败。若调用方取消先于 dispose 完成结算,该清理失败会继续作为受追踪的 owner activity 保留,直到后续 owner 或服务 dispose 消费并报告它,因此沙箱模式策略不会把清理失败误判为静默。后端或工具插件 reload 不会遗留会话:所有权持续存放在 `PtyService` 中,直到 agent 结束,与 [`ctx.tasks`](../../../../packages/tasks/tasks/README.md) 的服务持有记录模式一致。服务会先同步把会话预留给一次活跃发送,再返回该操作;后台发送同样会在 task id 对外可见前完成预留。第二次发送会以 `SEND_ACTIVE` 失败,因此输出与取消无法跨越操作所有权。 ### 安全与进程边界 @@ -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;低于 64 的值会被拒绝,以确保创建确认保留 registry 签发的 id;完整 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 状态包装、pre-execute 拒绝以及 post-execute 替换或阻断后,仍受该值限制;位于外层的 post-execute wrapper 会原样保留策略刻意返回的结构化多 block 内容。渲染器会为后缀预留空间并保持代码点边界,而不会把后端载荷上限当作面向模型结果的最终上限。 当 `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。 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index db41e4e868..78687b70b9 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -841,7 +841,7 @@ list(owner: Agent): PtySessionSnapshot[] Types: [Agent](../core-data-structures/core.md) · [PtyBackend](../core-data-structures/pty.md) · [PtyReadRequest](../core-data-structures/pty.md) · [PtyReadResult](../core-data-structures/pty.md) · [PtySendOperation](../core-data-structures/pty.md) · [PtySendRequest](../core-data-structures/pty.md) · [PtySessionId](../core-data-structures/pty.md) · [PtySessionSnapshot](../core-data-structures/pty.md) · [PtySignal](../core-data-structures/pty.md) · [PtySignalResult](../core-data-structures/pty.md) · [PtySpawnRequest](../core-data-structures/pty.md) · [PtySpawnResult](../core-data-structures/pty.md) -Source: [`packages/pty/pty/src/index.ts:104`](../../packages/pty/pty/src/index.ts) +Source: [`packages/pty/pty/src/index.ts:105`](../../packages/pty/pty/src/index.ts) ## `ctx.sandbox` — `SandboxProvider` (abstract seam) diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 24f0f9b252..237015cbac 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -42,8 +42,8 @@ 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), [`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/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/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), [`tool-pty`](../packages/pty/tool-pty), [`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) | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:81`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | diff --git a/packages/pty/pty/README.md b/packages/pty/pty/README.md index af1c3a9d42..4213a3458f 100644 --- a/packages/pty/pty/README.md +++ b/packages/pty/pty/README.md @@ -8,6 +8,7 @@ Owner-scoped persistent PTY seam. `PtyService` registers as `ctx.pty`, mints opa - Spawn cancellation preserves the caller's exact abort reason. Service disposal and owner loss remain distinct machine-routable failures after backend setup. - Owner and service disposal abort unpublished setup through a service-owned signal and await backend settlement plus rollback before returning. - A service rollback or backend-reported startup cleanup failure rejects the disposing lifecycle instead of claiming quiescence; the spawn caller still receives its exact cancellation reason. +- A backend cleanup failure that follows caller cancellation remains owner activity until owner or service disposal consumes and reports it, so lifecycle policy cannot mistake failed cleanup for quiescence. - `hasOwnerActivity(owner)` spans unpublished setup through final close, so lifecycle policy can fence the exact owner without a publication race. - A successful spawn publishes one `PtySessionId`. The optional `name` is owner-local display metadata, never authority. - One session accepts at most one live send operation. Reads and signals may observe it; another send fails until the operation settles. diff --git a/packages/pty/pty/src/index.ts b/packages/pty/pty/src/index.ts index f80089c90b..47c2a2662d 100644 --- a/packages/pty/pty/src/index.ts +++ b/packages/pty/pty/src/index.ts @@ -90,6 +90,7 @@ interface SessionRecord { } interface PendingSpawn { + readonly owner: Agent readonly controller: AbortController readonly settled: Promise cleanupFailure: { error: unknown } | undefined @@ -349,7 +350,7 @@ export class PtyService extends Service { private reserveSpawn(owner: Agent): SpawnReservation { const controller = new AbortController() const settlement = Promise.withResolvers() - const pending: PendingSpawn = { controller, settled: settlement.promise, cleanupFailure: undefined } + const pending: PendingSpawn = { owner, controller, settled: settlement.promise, cleanupFailure: undefined } const owned = this.pendingSpawns.get(owner) ?? new Set() owned.add(pending) this.pendingSpawns.set(owner, owned) @@ -357,13 +358,19 @@ export class PtyService extends Service { signal: controller.signal, release: (cleanupFailure) => { pending.cleanupFailure = cleanupFailure - owned.delete(pending) - if (owned.size === 0) this.pendingSpawns.delete(owner) + if (cleanupFailure === undefined) this.removePendingSpawn(pending) settlement.resolve() }, } } + private removePendingSpawn(pending: PendingSpawn): void { + const owned = this.pendingSpawns.get(pending.owner) + if (owned === undefined) return + owned.delete(pending) + if (owned.size === 0) this.pendingSpawns.delete(pending.owner) + } + private async abortPendingSpawns(owner: Agent | undefined, reason: PtyError): Promise { const pending = owner === undefined ? [...this.pendingSpawns.values()].flatMap(owned => [...owned]) @@ -371,6 +378,7 @@ export class PtyService extends Service { for (const spawn of pending) spawn.controller.abort(reason) await Promise.all(pending.map(spawn => spawn.settled)) const failures = pending.flatMap(spawn => spawn.cleanupFailure === undefined ? [] : [spawn.cleanupFailure.error]) + for (const spawn of pending) this.removePendingSpawn(spawn) if (failures.length > 0) { throw new AggregateError(failures, 'failed to roll back unpublished PTY setup') } diff --git a/packages/pty/pty/tests/service.spec.ts b/packages/pty/pty/tests/service.spec.ts index 1d9d1a1f94..caa255ac5b 100644 --- a/packages/pty/pty/tests/service.spec.ts +++ b/packages/pty/pty/tests/service.spec.ts @@ -160,6 +160,7 @@ describe('PtyService ownership and lifecycle', () => { const created = await ctx.pty.spawn(owner, { type: 'stub', name: 'main', cwd: '/tmp' }) expect(created).toMatchObject({ sessionId: 'pty-1', name: 'main', type: 'stub', pid: 123, motd: 'stub ready', status: { kind: 'running' } }) + expect(ctx.pty.hasOwnerActivity(owner)).toBe(true) expect(ctx.pty.list(owner)).toHaveLength(1) expect(ctx.pty.list(foreign)).toEqual([]) expect(() => ctx.pty.read(foreign, created.sessionId)).toThrow('belongs to another agent') @@ -256,6 +257,40 @@ describe('PtyService ownership and lifecycle', () => { await expect(pending).rejects.toBe(reason) }) + it.each(['owner', 'service'] as const)('retains caller-triggered backend cleanup failure until %s disposal', async (scope) => { + const ctx = await harness() + const started = Promise.withResolvers() + const cleanupFailure = new Error('backend cleanup failed') + ctx.pty.registerBackend({ + type: 'cleanup-failing', + spawn: ({ signal }) => new Promise((_resolve, reject) => { + if (signal === undefined) throw new Error('missing spawn signal') + started.resolve(undefined) + signal.addEventListener('abort', () => { + reject(new PtyBackendCleanupError(signal.reason, cleanupFailure)) + }, { once: true }) + }), + }) + const owner = stubAgent(ctx, 'owner') + ctx.agents.register(owner) + const controller = new AbortController() + const reason = new Error('cancelled by caller') + + const pending = ctx.pty.spawn(owner, { type: 'cleanup-failing' }, controller.signal) + await started.promise + controller.abort(reason) + + await expect(pending).rejects.toBe(reason) + expect(ctx.pty.hasOwnerActivity(owner)).toBe(true) + const internal = ctx.pty as unknown as { + disposeOwned(owner: Agent): Promise + disposeAll(): Promise + } + const disposal = scope === 'owner' ? internal.disposeOwned(owner) : internal.disposeAll() + await expect(disposal).rejects.toThrow('failed to clean up PTY lifecycle') + expect(ctx.pty.hasOwnerActivity(owner)).toBe(false) + }) + it.each([ { scope: 'owner', code: 'OWNER_NOT_LIVE' }, { scope: 'service', code: 'SERVICE_DISPOSING' }, diff --git a/packages/pty/tool-pty/README.md b/packages/pty/tool-pty/README.md index 8ec8601b6b..a3167ab552 100644 --- a/packages/pty/tool-pty/README.md +++ b/packages/pty/tool-pty/README.md @@ -11,7 +11,7 @@ Six model-facing tools over `ctx.pty`: `terminal_open`, `terminal_send`, `termin | `enableRunInBackground` | `true` | expose and accept `run_in_background`; false omits the schema field and rejects a forced undeclared argument | | `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. 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. +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. An outer `tools/post-execute` wrapper applies the same cap after a terminal pre-execute denial or single-text post-execute replacement/block; a structured multi-block policy result retains its shape. ## Model Experience @@ -53,11 +53,11 @@ 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 normalized error text and 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 terminal-owned or policy-produced single-text result is capped by `maxResultBytes` after normalized errors, denials, replacements, blocks, and generic task status text. Structured multi-block policy results retain their shape. Results remain in session history until compaction; incremental task reads do not repeat consumed output. #### Token effect -Data-dependent and bounded by `maxResultBytes`; each returned result remains in history until compaction. +Terminal-owned and policy-produced single-text results are data-dependent and bounded by `maxResultBytes`; a policy that deliberately substitutes structured multi-block content owns that content's bound. Each returned result remains in history until compaction. #### KV Cache effect diff --git a/packages/pty/tool-pty/src/index.ts b/packages/pty/tool-pty/src/index.ts index 5e75c97bec..5b1e3b7e9d 100644 --- a/packages/pty/tool-pty/src/index.ts +++ b/packages/pty/tool-pty/src/index.ts @@ -12,7 +12,7 @@ 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 { PostToolDecision, ToolExecutionResult } from '@deepseek-ai/dsh-tools' import { boundTerminalText, renderList, renderRead, renderSend, renderSendRead, renderSpawn } from './render.ts' declare module '@deepseek-ai/dsh-tasks' { @@ -95,9 +95,9 @@ function textResult(text: string, maxBytes: number): ContentBlock[] { return [{ type: 'text', text: boundTerminalText(text, maxBytes) }] } -function rawResultText(result: ToolResult): string | undefined { - if (result.content.length !== 1) return undefined - const block = result.content[0] +function rawContentText(content: readonly ContentBlock[]): string | undefined { + if (content.length !== 1) return undefined + const block = content[0] return block?.type === 'text' ? block.text : undefined } @@ -114,12 +114,17 @@ export function apply(ctx: Context, config: Config = {}): void { 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 => { - 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.on('tools/post-execute', async (exec, result, next): Promise => { + const decision = await next() + if (!TOOL_NAMES.has(exec.name)) return decision + const content = decision.kind === 'block' ? decision.feedback : decision.content ?? result.content + const raw = rawContentText(content) + if (raw === undefined) return decision + const bounded = textResult(raw, maxResultBytes) + return decision.kind === 'block' + ? { ...decision, feedback: bounded } + : { ...decision, content: bounded } + }, { prepend: true }) ctx.systemPrompt.section({ name: 'tool:pty', order: 106, @@ -206,7 +211,7 @@ export function apply(ctx: Context, config: Config = {}): void { }, presentResult(args, result) { if ((args as Partial).run_in_background === true || result.isError) return undefined - const raw = rawResultText(result) + const raw = rawContentText(result.content) return raw === undefined ? undefined : { card: 'terminal', output: raw } }, })) diff --git a/packages/pty/tool-pty/tests/tools.spec.ts b/packages/pty/tool-pty/tests/tools.spec.ts index 0466b02134..ebc2503a5a 100644 --- a/packages/pty/tool-pty/tests/tools.spec.ts +++ b/packages/pty/tool-pty/tests/tools.spec.ts @@ -217,6 +217,37 @@ describe('tool-pty foreground surface', () => { expect(Buffer.byteLength(text(background))).toBeLessThanOrEqual(64) }) + it('bounds terminal results after pre- and post-execute policy', async () => { + const { ctx, agent } = await setup(false, { maxResultBytes: 64 }) + ctx.on('tools/pre-execute', async (exec, next) => exec.name === 'terminal_list' + ? { kind: 'deny', reason: 'd'.repeat(1_000) } + : next()) + ctx.on('tools/post-execute', async (exec, _result, next) => { + if (exec.name === 'terminal_open') { + return { kind: 'accept', content: [{ type: 'text', text: 'a'.repeat(1_000) }] } + } + if (exec.name === 'terminal_read') { + return { kind: 'block', feedback: [{ type: 'text', text: 'b'.repeat(1_000) }] } + } + return next() + }) + + const denied = await call(ctx, 'terminal_list', {}, agent) + expect(denied.isError).toBe(true) + expect(Buffer.byteLength(text(denied))).toBeLessThanOrEqual(64) + expect(text(denied)).toContain('[output truncated]') + + const replaced = await call(ctx, 'terminal_open', { type: 'stub' }, agent) + expect(replaced.isError).toBe(false) + expect(Buffer.byteLength(text(replaced))).toBeLessThanOrEqual(64) + expect(text(replaced)).toContain('[output truncated]') + + const blocked = await call(ctx, 'terminal_read', { sessionId: 'pty-1' }, agent) + expect(blocked.isError).toBe(true) + expect(Buffer.byteLength(text(blocked))).toBeLessThanOrEqual(64) + expect(text(blocked)).toContain('[output truncated]') + }) + 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'