fix(pty): keep the controlled prompt so persistent bash settles fast

tool-bash-persistent overwrote the backend's PS1, so terminal-bash prompt
readiness never matched and every send degraded to the 3.5s silence tier
(idleSilenceMs + handoffGraceMs) under production defaults.

The controlled PROMPT_COMMAND now re-asserts PS1 before every prompt, so an
in-shell override never survives to the next prompt. The tool initializes
with stty -echo alone and detects the no-end-marker fallback through the
seam's stdin_read wait reason instead of matching its own prompt text.

Tool calls drop from 7180/3560/3566 ms to 355/88/91 ms (spawn+init+echo,
echo, pwd; darwin, production defaults). The loader composition suite now
pins the fast path by pushing idleSilenceMs beyond the send bound, and a
real-PTY case proves PS1 self-healing.

Fixes #2585
This commit is contained in:
Yichen Jiang
2026-08-15 11:06:57 +08:00
parent 5bb600f9fb
commit a8dc6f9776
18 changed files with 145 additions and 40 deletions
@@ -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 packages/shell/tool-bash-persistent/README.md
README.md: 37c259443cba566350d8ec7963857d5f6ce7396c
README.zh.md: 8f3cf4e4d20aad1537b48644cee1a9d5706f4b3c
README.md: 606920d087b42344f34b70103e167b0046d3cfd5
README.zh.md: dd88db87cb617fb2f4d35ada9550d0142e32e979
@@ -33,7 +33,7 @@ Prefix-stable while the configured description and schema remain unchanged.
#### What the model sees
Commands share one shell per Agent, so cwd, exported variables, activated environments, functions, and background jobs persist across calls. Results exclude private completion markers and the shell prompt. A nonzero wrapped command appends `[exit code: N]`; a shell that exits before reporting that status instead appends `[shell exited: code N]`, `[shell killed by signal: SIG]`, or `[shell exited]` when the backend supplies neither, then resets and tells the model that the next call starts fresh. Long output keeps the earliest retained prefix plus a clipping notice. If the PTY has already dropped that prefix, the result says so explicitly instead of presenting a tail as complete output. Timeout returns bounded partial output, closes the uncertain shell, and reports the reset.
Commands share one shell per Agent, so cwd, exported variables, activated environments, functions, and background jobs persist across calls. Results exclude private completion markers. When the shell reads stdin again without having printed the completion marker — after `exec`, an interrupt, or an interactive foreground child whose stdin wait the provider proves — the call returns the captured partial output, which can end with the backend's own prompt text. A nonzero wrapped command appends `[exit code: N]`; a shell that exits before reporting that status instead appends `[shell exited: code N]`, `[shell killed by signal: SIG]`, or `[shell exited]` when the backend supplies neither, then resets and tells the model that the next call starts fresh. Long output keeps the earliest retained prefix plus a clipping notice. If the PTY has already dropped that prefix, the result says so explicitly instead of presenting a tail as complete output. Timeout returns bounded partial output, closes the uncertain shell, and reports the reset.
#### Token effect
@@ -46,5 +46,6 @@ Append-only tool results follow the reusable request prefix.
## Known Limitations and Deferred Work
- The tool requires an owning Agent and a real PTY backend.
- An interactive foreground child (for example a REPL) returns early with partial output only where the subprocess provider proves its stdin wait; elsewhere the call runs to `timeoutMs`.
- Explicit `exit` and timeout discard shell state. Cancellation also resets and discards the result, even when a complete status marker is already observable; the next call starts a fresh shell.
- Environment facts such as network access and package mirrors belong in the configured `description`, not this package's default.
@@ -33,7 +33,7 @@
#### 模型所见
每个 Agent 的命令共享一个 shell,因此 cwd、导出的环境变量、已激活环境、函数和后台任务会跨调用保留。结果不包含私有完成标记 shell 提示符。经封装的命令以非零状态结束时,结果会追加 `[exit code: N]`;若 shell 在报告该状态前退出,则改为追加 `[shell exited: code N]``[shell killed by signal: SIG]`,或在后端既未提供退出码也未提供信号时追加 `[shell exited]`;随后重置 shell,并告知模型下次调用从新 shell 开始。长输出保留仍可读取的最早前缀并追加截断提示;若 PTY 已丢弃真正的开头,结果会明确说明,而不是把尾部伪装成完整输出。超时返回有界的部分输出、关闭状态不确定的 shell,并报告该重置。
每个 Agent 的命令共享一个 shell,因此 cwd、导出的环境变量、已激活环境、函数和后台任务会跨调用保留。结果不包含私有完成标记。当 shell 在未打印完成标记的情况下再次读取 stdin 时——例如 `exec`、中断,或提供方能证明其 stdin 等待的交互式前台子进程——调用返回已捕获的部分输出,其末尾可能带有后端自己的提示符文本。经封装的命令以非零状态结束时,结果会追加 `[exit code: N]`;若 shell 在报告该状态前退出,则改为追加 `[shell exited: code N]``[shell killed by signal: SIG]`,或在后端既未提供退出码也未提供信号时追加 `[shell exited]`;随后重置 shell,并告知模型下次调用从新 shell 开始。长输出保留仍可读取的最早前缀并追加截断提示;若 PTY 已丢弃真正的开头,结果会明确说明,而不是把尾部伪装成完整输出。超时返回有界的部分输出、关闭状态不确定的 shell,并报告该重置。
#### Token 影响
@@ -46,5 +46,6 @@
## 已知限制与延后工作
- 工具需要拥有它的 Agent 和真实 PTY 后端。
- 交互式前台子进程(例如 REPL)只有在进程管理提供方能证明其 stdin 等待时才会提前返回部分输出;否则调用会一直运行到 `timeoutMs`
- 显式 `exit` 与超时会丢弃 shell 状态。取消同样会重置 shell 并丢弃结果,即使已经能观察到完整状态标记也是如此;下次调用创建新 shell。
- 网络访问、软件包镜像等环境事实应写入配置的 `description`,而非包默认描述。
@@ -7,7 +7,7 @@ import { randomUUID } from 'node:crypto'
import type { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { TerminalReadResult, TerminalSendResult, TerminalSessionId } from '@deepseek-ai/dsh-terminal'
import type { TerminalReadResult, TerminalSessionId } from '@deepseek-ai/dsh-terminal'
import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
import { defineTool } from '@deepseek-ai/dsh-tools'
@@ -15,7 +15,6 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
const TRUNCATED_MESSAGE = '<response clipped><NOTE>To save on context only part of this file has been shown to you. You should retry this tool after you have searched inside the file with `grep -n` in order to find the line numbers of what you are looking for.</NOTE>'
const LOST_PREFIX_MESSAGE = '<response clipped><NOTE>The beginning of this command output was dropped by the terminal scrollback limit. The following text is the earliest retained output.</NOTE>\n'
const SHELL_RESET_MESSAGE = 'The persistent bash shell was reset; the next bash call starts from the workspace with a fresh current directory and environment.'
const SHELL_PROMPT = '__DSH_PERSISTENT_BASH_PROMPT__ '
const TIMEOUT_CODE = 'PERSISTENT_BASH_TIMEOUT'
// One page is enough to find a just-emitted completion marker; the full
// scrollback is assembled only when a command settles or needs partial output.
@@ -82,12 +81,8 @@ function wrapCommand(command: string, marker: CommandMarkers): string {
return `printf '%s\\n' ${quoteForBash(marker.start)}; eval -- ${quoteForBash(command)}; __dsh_persistent_bash_status=$?; printf '%s%s\\n' ${quoteForBash(marker.end)} "$__dsh_persistent_bash_status"`
}
function stripPrompt(text: string): string {
let result = text.replace(/\r?\n$/, '')
while (result.endsWith(SHELL_PROMPT)) {
result = result.slice(0, -SHELL_PROMPT.length)
}
return result.endsWith('\n') ? result.slice(0, -1) : result
function trimTrailingNewline(text: string): string {
return text.replace(/\r?\n$/, '')
}
function commandOutput(
@@ -101,18 +96,12 @@ function commandOutput(
const startMarker = text.lastIndexOf(marker.start, end)
const start = startMarker < 0 ? 0 : startMarker + marker.start.length
return {
text: stripPrompt(text.slice(start, end).replace(/^\r?\n/, '')),
text: trimTrailingNewline(text.slice(start, end).replace(/^\r?\n/, '')),
incomplete: startMarker < 0,
exitCode: Number(status),
}
}
function promptCompleted(result: TerminalSendResult): boolean {
return result.viewport.endsWith(SHELL_PROMPT)
|| result.viewport.endsWith(`${SHELL_PROMPT}\r\n`)
|| result.viewport.endsWith(`${SHELL_PROMPT}\n`)
}
function partialOutput(
snapshot: RetainedOutput,
marker: CommandMarkers,
@@ -122,7 +111,7 @@ function partialOutput(
const startMarker = snapshot.text.lastIndexOf(marker.start)
if (startMarker >= 0) {
return {
text: stripPrompt(snapshot.text.slice(startMarker + marker.start.length).replace(/^\r?\n/, '')),
text: trimTrailingNewline(snapshot.text.slice(startMarker + marker.start.length).replace(/^\r?\n/, '')),
incomplete: false,
}
}
@@ -133,7 +122,7 @@ function partialOutput(
const fallbackEnd = afterStart.lastIndexOf(marker.end)
const beforeEnd = fallbackEnd < 0 ? afterStart : afterStart.slice(0, fallbackEnd)
return {
text: stripPrompt(beforeEnd.replaceAll(SHELL_PROMPT, '')),
text: trimTrailingNewline(beforeEnd),
incomplete: fallbackTruncated || fallbackStart < 0,
}
}
@@ -243,8 +232,10 @@ function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShell
live.delete(owner)
}, 'tool-bash-persistent owner cache cleanup')
}
// Echo suppression only: the prompt stays the backend's own, so the
// backend's prompt-based readiness detection keeps working.
const setup = ctx.terminals.startSend(owner, spawned.sessionId, {
text: `stty -echo; PS1=${quoteForBash(SHELL_PROMPT)}`,
text: 'stty -echo',
submit: true,
signal: combinedSignal,
})
@@ -339,7 +330,11 @@ async function executeCommand(
SHELL_RESET_MESSAGE,
].filter(part => part.length > 0).join('\n')
}
if (promptCompleted(result)) {
// The shell reads stdin again (its prompt, or a foreground child's own
// read) without having printed the end marker — e.g. `exec`, an interrupt,
// or an interactive child. Return what was captured instead of spinning
// until the command deadline.
if (result.waitReason === 'stdin_read') {
const snapshot = retainedScrollback(ctx, owner, id, latest)
return renderCaptured(
partialOutput(snapshot, marker, fallback, fallbackTruncated),
@@ -84,7 +84,10 @@ suite('persistent Bash through a real cordis.yml Loader composition', () => {
' config:',
' pollIntervalMs: 10',
' exactProbeAfterMs: 20',
' idleSilenceMs: 100',
// The silence tier is pushed beyond the send bound, so no send below can
// settle as inferred_idle: every case proves the controlled-prompt fast
// path that the production defaults (3.5s silence) would otherwise mask.
' idleSilenceMs: 30000',
' handoffGraceMs: 100',
' scrollbackLines: 20000',
' timeoutMs: 2000',
@@ -154,6 +157,12 @@ suite('persistent Bash through a real cordis.yml Loader composition', () => {
expect(large).toContain('<response clipped>')
expect(large).not.toContain('beginning of this command output was dropped')
// `exec` replaces the wrapper before its end marker prints; the seam's
// stdin_read readiness is what returns the replacement shell's prompt
// instead of spinning until the tool deadline.
const execed = text(await execute('exec-replacement', 'exec bash --noprofile --norc -i'))
expect(execed).toBe('dsh> ')
const exited = text(await execute('exit', 'exit'))
expect(exited).toContain('next bash call starts from the workspace')
expect(text(await execute('after-exit', 'printf "%s\\n" "$PWD"'))).toBe(root)
@@ -100,7 +100,7 @@ type StubMode =
| 'paged-scrollback'
class StubPtySession implements TerminalBackendSession {
readonly motd = '__DSH_PERSISTENT_BASH_PROMPT__ '
readonly motd = 'stub> '
readonly pid = 123
statusValue: TerminalSessionStatus = { kind: 'running' }
scrollback = this.motd
@@ -325,7 +325,7 @@ describe('tool-bash-persistent', () => {
expect(ctx.tools.get('bash')).toBeUndefined()
})
it('handles inferred idle, prompt fallback, shell exit, clipping, and cleanup', async () => {
it('handles inferred idle, stdin_read fallback, shell exit, clipping, and cleanup', async () => {
const { ctx, owner, stub, fiber } = await setup({
backendType: 'stub',
maxOutputChars: 10,
@@ -338,18 +338,16 @@ describe('tool-bash-persistent', () => {
session.mode = 'incremental-fallback'
session.scrollback = ''
expect(text(await call(ctx, owner, 'incremental fallback'))).toBe('increment')
expect(text(await call(ctx, owner, 'incremental fallback'))).toContain('increment')
session.mode = 'prompt-only'
const promptFallback = text(await call(ctx, owner, 'bad {'))
expect(promptFallback).toContain('bash: synt')
expect(promptFallback).not.toContain('DSH_PERSISTENT_BASH_PROMPT')
session.mode = 'prompt-crlf'
session.scrollback = ''
const crlfPromptFallback = text(await call(ctx, owner, 'bad {'))
expect(crlfPromptFallback).toContain('bash: synt')
expect(crlfPromptFallback).not.toContain('DSH_PERSISTENT_BASH_PROMPT')
session.mode = 'end-only'
session.scrollback = ''
@@ -435,7 +433,7 @@ describe('tool-bash-persistent', () => {
expect(text(await call(ctx, owner, 'paged output'))).toBe('hello from stub')
})
it('sanitizes a prompt fallback reached after multiple polling rounds', async () => {
it('returns a stdin_read fallback reached after multiple polling rounds', async () => {
const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 })
await call(ctx, owner, 'warm up')
const session = stub.sessions[0]!
@@ -444,7 +442,8 @@ describe('tool-bash-persistent', () => {
const result = text(await call(ctx, owner, 'bad {'))
expect(result).toContain('partial syntax output')
expect(result).toContain('bash: syntax error')
expect(result).not.toContain('DSH_PERSISTENT_BASH_PROMPT')
// The backend owns the prompt text, so the fallback retains it verbatim.
expect(result.endsWith('stub> ')).toBe(true)
expect(result).not.toContain('DSH_PERSISTENT_BASH_START')
})