交互式终端模型工作流
一句话版:
terminal是六个面向模型的持久终端工具(terminal_open/terminal_send/terminal_read/terminal_signal/terminal_close/terminal_list),在 DSH 的ctx.terminals服务缝上提供 owner 隔离的交互式 shell/REPL——前台发送等你提示、走ctx.jobs的后台发送给你 job id,并用真正的进程组信号中断它。
子进程篇七节讲了"DSH 怎么分配一个 PTY、ctx.terminals 服务缝怎么路由到 terminal-bash 后端"。这一篇站到模型面:模型实际看到的六个工具长什么样、怎么编排一次完整的交互会话。
一、定位:机制层的模型呈现
packages/terminal/tool-terminal 在你装了 terminal 行之后,把那层受管终端机制映射成六个模型可见的工具。分层的边界很清楚:
| 层 | 包 / ctx | 负责什么 | 本文覆盖 |
|---|---|---|---|
| 终端机制 | terminal | ctx.terminals 服务缝:铸不透明 session id、owner 栅栏、按名路由后端、dispose 时等 quiescence | 背景 |
| 后端 | terminal-bash | 在 ctx.subprocess.spawnTerminal 上起持久 shell(type: shell) | 背景 |
| 模型工具 | tool-terminal(ctx.terminals 之上) | 六个工具 schema、owner 认证、收尾上限、系统提示词、后台接 ctx.jobs | 本篇正文 |
tool-terminal 的注入只有三个:['terminals', 'tools', 'systemPrompt']——它不碰 node-pty、不碰沙箱、不碰任务调度,只管把机制暴露成模型工作流。
二、六个工具拆解
下面按 terminal_open → terminal_close 的完整生命周期介绍。所有参数/返回值都来自 packages/terminal/tool-terminal/src/index.ts 的 schema。
1. terminal_open — 建会话
terminal_open(type?, name?, cwd?) → { sessionId, name?, type, pid?, status, motd }
type(必填):注册的终端后端类型,通常就是"shell"。name(选填):owner 本地的显示名,如"main"、"gdb"。cwd(选填):初始工作目录,缺省为部署工作区根。- 返回带
motd的会话快照:模型靠它拿"这个会话该叫什么"的提示。
源码 execute(packages/terminal/tool-terminal/src/index.ts):
const result = await ctx.terminals.spawn(requireAgent(exec.agent), {
type: args.type,
...args.name !== undefined ? { name: args.name } : {},
...args.cwd !== undefined ? { cwd: args.cwd } : {},
}, exec.signal)
return result
2. terminal_send — 写输入(前台 / 后台)
terminal_send(sessionId, text, submit?, run_in_background?) →
前台: { kind:'foreground', viewport, waitReason, sessionStatus, truncated }
后台: { kind:'background', jobId }
submit缺省true(发回车);要发控制字符或不完整的 REPL 输入就设false。- 前台发送默认"等"——一直等到四件事之一发生,返回的
waitReason精确告诉你是哪一件:
waitReason | 含义 |
|---|---|
stdin_read | 后台又读到一次输入(程序还要后续输入) |
inferred_idle | 推断静默,认为输出告一段落 |
timeout | 达到等待超时 |
session_exit | 这个 session 已退出 |
⚠️ 系统提示词原话:
inferred_idle或timeout并不能证明前台命令已退出——静默≠结束,超时≠退出。要确认状态看sessionStatus。
- 后台模式(
run_in_background: true)不阻塞模型:预检 + 该会话独占发送预留都在返回 job id 之前完成,然后返回{ kind:'background', jobId }接通用ctx.jobs。收集用job_output,停止用job_kill——而job_kill对这条pty-sendjob 会转发成向当前前台进程组发真实SIGINT。
源码后台分支(packages/terminal/tool-terminal/src/index.ts):
const jobId = jobs.start({
kind: 'pty-send',
label: `${id}: ${args.text || '(input)'}`,
owner,
outputLimitBytes: maxResultBytes,
run: () => {
const operation = ctx.terminals.startSend(owner, id, request)
return {
cancel: () => { cancelRequested = true; operation.cancel() },
done: operation.done.then(
result => ({ status: cancelRequested ? 'killed' : 'completed', detail: sendDetail(result) }),
(error) => ({ status: 'failed', detail: String(error) }),
),
readOutput: () => renderSendRead(operation.readOutput()),
}
},
})
return { kind: 'background', jobId }
kind: 'pty-send' 是这个插件对 @deepseek-ai/dsh-jobs 的 JobKindMap 的一份声明:
declare module '@deepseek-ai/dsh-jobs' {
interface JobKindMap { 'pty-send': 'pty-send' }
}
前台发送用"终端调用/结果卡片"呈现,后台发送用通用执行卡片。
3. terminal_read — 有界翻页,不发输入
terminal_read(sessionId, offset?, count?) → { text, totalLines, lineBegin, lineEnd, truncated }
offset缺省0(相对最新的偏移);count缺省500(后端有 cap)。- 返回一页带分页标记的保留输出,不发任何输入:模型想"复盘前面"而不打扰正在等输入的程序时用它。
truncated表示这页没装下被裁了,配合lineBegin/lineEnd/totalLines决定要不要往前翻。
4. terminal_signal — 发信号
terminal_signal(sessionId, signal) → { delivered:true, targetPgid }
signal是枚举:SIGINT/SIGTERM/SIGKILL/SIGTSTP/SIGHUP,发到当前前台进程组(返回targetPgid)。- 发向 shell 自身的
SIGKILL会被拒绝——要整个干掉就terminal_close,不要对 shell 进程发SIGKILL。
5. terminal_close — 关掉并等进程树消失
terminal_close(sessionId) → { sessionId, outcome:'closed'|'already-closing' }
- 关闭一个持久会话,并等到它俘获的受管进程树完全终止才返回。
outcome: 'closed'表示这次由你关掉;'already-closing'表示它正在关闭,这次是幂等确认。
源码 execute:
const closed = await ctx.terminals.kill(requireAgent(exec.agent), id)
return { sessionId: id, outcome: closed ? 'closed' : 'already-closing' }
6. terminal_list — 盘点当前 agent 的会话
terminal_list() → [ { sessionId, name?, type, pid?, status } ... ]
- 列当前发起 agent 自己持久会话的最新快照,每个会话一行快照。
工具速查表
| 工具 | 作用 | 关键返回 |
|---|---|---|
terminal_open | 建持久会话 | sessionId + motd |
terminal_send | 写输入;前台等,后台给 job id | waitReason/sessionStatus/viewport 或 {kind:'background', jobId} |
terminal_read | 有界翻页读保留输出,不发输入 | totalLines/lineBegin/lineEnd/truncated |
terminal_signal | 给前台进程组发 SIGINT/SIGTERM/SIGKILL/SIGTSTP/SIGHUP | targetPgid |
terminal_close | 关会话,等进程树完全终止 | closed/already-closing |
terminal_list | 列当前 agent 全部会话快照 | 数组 |
三、核心使用约定
owner 隔离(每步都 requireAgent)
源码里有个硬性前提:六个工具的执行都过 requireAgent(exec.agent):
function requireAgent(agent: Agent | undefined): Agent {
if (agent === undefined) throw new Error('terminal tools require an initiating agent')
return agent
}
owner 身份来自发起那次工具执行的 exact Agent。所以就算模型获知了另一个 agent 的 session id,也撬不动对方的会话——每次 spawn/send/read/signal/kill/list 都精准栅栏到同一个 owner。
系统提示词段(order: 106)
插件挂一段固定指引,提点该什么时候用终端:
ctx.systemPrompt.section({
name: 'tool:pty',
order: 106,
text: 'Use a terminal session only when work needs persistent terminal state or interactive stdin; prefer shell/read/write/edit for bounded one-shot operations. Track every terminal session id and close sessions that no longer matter. An inferred_idle or timeout result does not prove the foreground command exited.',
})
要点(是给模型听的,也是写 prompt 时该记住的):
- 只在需要持久终端状态或交互 stdin 时开终端;一次性有界操作,优先
shell/read/write/edit。 - 记下每个 session id,用后关掉,别漏
terminal_close。 inferred_idle或timeout不代表前台命令已退出。
配置
| 键 | 默认 | 最小 | 含义 |
|---|---|---|---|
enableRunInBackground | true | — | 公开并接受 run_in_background;设 false 时 schema 直接省略该字段,抢占传入未声明参数会被拒 |
maxResultBytes | 262144 | 64 | 每个完整终端/任务输出结果的 UTF-8 上限;在等待、会话、分页、截断、任务状态元数据都拼进去后计算 |
- 加载时校验两个值:
maxResultBytes必须是安全整数且 ≥64(MIN_MAX_RESULT_BYTES)。 - 最小 64 字节是刻意选的:保证注册表签发的每个 session id / job id 都能完整出现在创建确认里。
- 渲染把控制元数据和截断标记(
[output truncated],渲染层用 UTF-8 边界切割)预留空间,再裁正文。
四、编排一次完整交互
典型的长会话流程——打开,发命令,读到静默或被程序要求继续输入,需要时信号,最后关闭:
验证/试一试(在你跑得起来 DSH 的环境里),一个真实 REPL:
# 1) 发起一个调用终端的 session
dsh run --agent-terminal-demo
# 2) 会话日志里看 terminal_* 工具事件的顺序
zstdcat ~/.dsh/sessions/*/*/session.jsonl.zstd | grep -E '"terminal_' | head
# 3) 组合树里确认 tool-terminal 行已挂载
dsh web --dump-config | grep -iE "tool-terminal|terminal-bash"
模型侧想亲历一遍:让 agent
terminal_open起一个shell,发python3,再发print(2+2),对着stdin_read的等待继续投喂,然后观察返回的waitReason从stdin_read切到inferred_idle或session_exit,最后terminal_close清掉。
五、和子进程篇的分工
别和 子进程与终端 混:
- 子进程篇在机制层:
ctx.terminals服务缝 +terminal-bash后端,以及再底下的spawnTerminal——它们负责"怎么分配/路由/清理一个 PTY"。 - 本篇在模型层:那六个工具叫什么、参数怎么填、返回怎么读、怎么编排一次交互会话,以及后台怎么接
ctx.jobs。 - 工具全览见 内置工具 的"终端类"。
| 机制层(subprocess 篇) | 模型层(本篇) | |
|---|---|---|
| 谁 | terminal / terminal-bash | tool-terminal |
| 交付物 | ctx.terminals 服务缝、后端 | 六个工具 schema + owner 认证 + 提示词 + 收尾上限 |
| 面向谁 | 其他插件 / 后端 | 模型 |