Shell 能力
在[子进程](/tether/subprocess.html)原语之上提供一层 shell 执行:每次调用起一个全新的
非交互进程,跑完就结束;到期未落定可以继续存活、由调用方 kill,或交给工具层转后台 job。对应实现:src/Tether.Shell/(seam)、
src/Tether.Shell.Local/(bash / pwsh Provider)、src/Tether.Shell.Tools/(模型可见工具)。
resolve 与 execute 两段式
public interface IShellExecutor
{
ShellExecSpec Resolve(ShellExecRequest request);
ValueTask<IShellExecution> Execute(
ShellExecSpec spec,
CancellationToken cancellation = default);
}
调用方先把请求过一遍 Resolve,再交给 Execute。分成两步是为了让”默认值从哪来”这件事可见:
Resolve负责用部署默认值填上 cwd、超时、输出预算,并给逐次覆盖值设上限;Execute拿到的是已解析的规格,不允许再次施加默认值。它立即返回一个活的IShellExecution句柄——spawn 一旦成功就保持运行,由调用方决定怎么收敛。
IShellExecution 是 IShellProcess 加上 ResultAsync():进程的原生控制面
(Status、Kill()、Done、Observed)与前台投影分离,ResultAsync()
memoized,只在基础设施失败时抛。已取消的 token 视为已触发:Execute 见到
pre-cancelled 信号直接抛取消,等同上游 prepare 阶段被取消。
到期行为由 OnExpiry 决定
ShellExecRequest/ShellExecSpec 的 OnExpiry 取 Kill(默认)或 None:
Kill:到期相当于已发出的中止——进程树收 SIGTERM(GraceMs后 SIGKILL), 结果TimedOut=true。None:deadline 不接 kill 路径,到期后句柄仍Running,调用方可以用Kill()或继续等Done收敛。准备阶段(resolve→spawn 之间)到期则直接 settle 成 timed-out 的句柄:Status落定、ResultAsync()返回TimedOut=true的空结果。
进程句柄与观察面
public interface IShellProcess
{
ShellProcessStatus Status { get; } // Running | Completed | Killed
int? ExitCode { get; }
string? Signal { get; } // 信号名(如 "SIGTERM"),信号致死才非空
Task Done { get; } // 进程落定(含被 kill),永不 fault
ShellSandboxInfo? Sandbox { get; } // confined executor 落定后才非空
IShellObservedStreams Observed { get; }
ShellProcessRead ReadOutput(); // 消费式增量读;丢数据时 Lossy=true
bool Kill(); // 幂等;已落定返回 false
}
public interface IShellObservedStreams // 非消费的偏移读面,同一份捕获流
{
ISubprocessOutputReader Stdout { get; }
ISubprocessOutputReader Stderr { get; }
}
spawn 失败不抛:Execute settle 成一个 Killed 句柄,
Observed.Stderr.ReadFrom(0) 带 spawn failed: … 注记,ResultAsync()
以原始 ShellException 失败——“拿不到进程”和”进程被 kill”对调用方用同一个
形状。
请求与规格
var request = new ShellExecRequest("rg --files | head -20")
{
Workdir = "/repo", // 可选,Resolve 填默认
TimeoutMs = 30_000, // 可选,Resolve 会按上限截断
OnExpiry = ShellExpiryPolicy.Kill, // 默认;None = 到期不 kill
Stdin = "input text", // 可选,写入后关闭
Env = new Dictionary<string, string> { ["LANG"] = "C" },
};
var spec = shell.Resolve(request);
var execution = await shell.Execute(spec, token); // 立即返回活句柄
var result = await execution.ResultAsync(); // memoized 前台投影
命令文本作为单个 argv 元素交给 bash -c 或 pwsh -Command。环境条目在子进程凭据擦洗之后合并。
StdoutMaxBytes 不对模型开放
ShellExecRequest.StdoutMaxBytes 是给受信任的插件调用方用的前台捕获预算,
模型可见的工具不暴露这个参数——否则模型可以自己抬高输出上限,绕过部署设定。
命令结果不是错误
这条 seam最重要的一个划分:命令跑出非零退出码不是异常。
public sealed class ShellRunResult
{
public int? ExitCode { get; } // null = 进程没有退出码
public string? Signal { get; } // 信号名(如 "SIGTERM")
public bool TimedOut { get; }
public bool Aborted { get; }
public int TimeoutMs { get; } // 本次实际生效的超时
public ShellCollectedOutput Stdout { get; }
public ShellCollectedOutput Stderr { get; }
public ShellSandboxInfo? Sandbox { get; } // confined executor 才非空
}
TimedOut 与 Aborted 互斥,构造时就校验——它们表示”哪个原因最先把命令截断”,同时为真是自相矛盾的状态。
只有基础设施失败才抛 ShellException,带稳定错误码:
| 码 | 含义 |
|---|---|
SHELL_MISSING | 配置的 shell 可执行文件解析不到 |
SHELL_BAD_CWD | 解析出的工作目录不存在 |
SHELL_SPAWN | 进程起不来 |
SHELL_MISSING/SHELL_BAD_CWD 在 Execute 内直接抛;SHELL_SPAWN 走前面说的
Killed 句柄,ResultAsync() 才抛。也就是说:ls /nonexistent 返回退出码 2 是
正常结果;找不到 bash 本身才是异常。这个分界让上层不必用 try/catch 处理日常的命令失败。
输出收集
每个流给出保留文本、是否被截断、以及完整流的溢写路径:
public sealed class ShellCollectedOutput
{
public string Text { get; } // 截断时是尾部
public bool Truncated { get; }
public string? SpillPath { get; }
}
语义与子进程层的有界收集一致:内存里留尾部,完整流可选溢写到文件,溢写超限则整体丢弃、路径变空。
环境注入(shell-env)
shell-env catalog 行提供 IShellEnvRegistry:shell 执行的 env 面 = built-in 键 + contributor 合并。
- built-in:
TETHER_HOME(resolve 与AppBoot.ResolveHome同规则)、TETHER_SHELL="1"、agent 会话带TETHER_SESSION_ID;仅当 launcher 发布了ProfileContext才注入TETHER_PROFILE/TETHER_PROFILE_DIR——headless 无 profile 不注入。 - 五个 built-in 键保留,contributor 声明即抛;contributor 键必须
TETHER_前缀 +^[A-Z][A-Z0-9_]*$,描述非空,name 与 key 全局唯一。 - contributor 形状
{name, variables{key→description}, resolve(execution)}:collect按 name 序合并,resolve 只能产声明过的 key(产了未声明的即InvalidOperationException);list按 key 序枚举声明。 shell-tool请求里的显式env项在 registry 产出之后合并(显式覆盖)。
本地 Provider
两个插件提供同一个 IShellExecutor 服务键,装配时选其一:
catalog.Register<LocalShellOptions>("bash", options => new LocalBashPlugin(options));
catalog.Register<LocalShellOptions>("pwsh", options => new LocalPwshPlugin(options));
两者都声明 Inject = [typeof(ISubprocessRuntime)]——它们不自己碰进程 API,而是等子进程 seam就位后建在其上。
部署默认值在 LocalShellOptions:
| 选项 | 默认 | 含义 |
|---|---|---|
Cwd | 进程当前目录 | 默认工作目录 |
TimeoutMs | 120000 | 默认前台超时 |
MaxTimeoutMs | 600000 | 逐次覆盖的上限 |
MaxOutputBytes | 64000 | 每流内存输出上限 |
MaxSpillBytes | 64 MiB | 每流溢写文件上限 |
GraceMs | 3000 | SIGTERM 到 SIGKILL 宽限 |
Executable | 无 | 显式可执行文件,缺省则解析 bash / pwsh |
pwsh 的发现顺序
Windows 上 pwsh 不一定在 PATH 里,所以有一条显式的发现链:
- 配置里给了显式路径就用它;
- Windows 上依次尝试
%ProgramFiles%\PowerShell\7\pwsh.exe、PATH 各条目下的pwsh.exe、%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe,取第一个存在的; - 都没有则回退到裸名字
pwsh,交给子进程 seam的 PATH 搜索。
模型可见的工具
catalog.Register("bash-tools",
() => new BashToolsPlugin(workspace, new ShellToolOptions()));
catalog.Register("pwsh-tools",
() => new PwshToolsPlugin(workspace, new ShellToolOptions()));
两个插件都声明 Inject = [typeof(IShellExecutor), typeof(IToolRegistry), typeof(ISystemPromptProvider)],注册进工具管线并把工具提示挂上 system prompt 的 tool:bash/tool:pwsh 段。
模型看到的参数:command(必填)、description(必填——命令的一句话说明)、timeoutMs、workdir;挂的 executor 是 confining 时再加 sandbox_permissions + justification(变宽申请必须配对给非空理由,同模式或空白 justification 视为未给);装配上允许后台(EnableRunInBackground,默认 true)时加 run_in_background。
ShellToolOptions:
| 选项 | 默认 | 含义 |
|---|---|---|
EnableRunInBackground | true | 广告 run_in_background 并跟随 registry 出现/消失切 schema |
PromoteOnTimeout | true | 前台超时是否把命令转后台 job |
三种结果形状
工具的输出 schema 是 background | promoted | foreground union:
- foreground:
WaitAsync在timeoutMs内落定——Remove job 后返回{kind:'foreground', exitCode, signal, timedOut, aborted, stopped, timeoutMs, stdout, stderr, sandbox?, rendered}。命令本身已Start进 registry(有IJobRegistry时),所以外部对它job_kill能带到 kill reason——结果里stopped带 reason,渲染含[stopped: …];调用方 token abort 则是 kill +TOOL_ABORTED错误。 - promoted:超时仍未落定(
PromoteOnTimeout开且有 registry)——返回{kind:'promoted', jobId, timeoutMs, output, rendered},output是已观察输出的一次消费读做种子,rendered带[still running after Nms; moved to background job <id>]。准备阶段超时直接给空 timed-out 结果。 - background:
run_in_background=true立返{kind:'background', jobId}。EnableRunInBackground=false或 registry 缺失分别回固定文案 “run_in_background is disabled for this deployment” / “background jobs unavailable”。
schema 随 registry 热切换
EnableRunInBackground 开启时插件先注册一份无 run_in_background 的 foreground-only 定义,同时在 Inject([IJobRegistry]) 子 scope 里监听 registry:registry 出现 → 换成带 run_in_background 的定义;消失 → 恢复 foreground-only。timeoutMs 的描述文案也随 PromoteOnTimeout 开关切换(“超时转后台” vs “超时终止”)。
job 准入(Start)失败时不硬失败:warn 一条 internal/error 事件并回落 executor Kill deadline 的前台路径。
Aborted 会转成取消异常
工具层拿到 result.Aborted 为真(或调用方 token 撤销)时 kill 掉进程并抛
OperationCanceledException(TOOL_ABORTED),而不是把"被取消"
渲染成文本给模型。取消是调用方的意图,不该被当成命令输出。
结果如何渲染给模型
ShellResultText.Render 把结构化结果拍平成一段文本,规则是固定的:
- 先是 stdout;
- stderr 非空时追加一段带
[stderr]标记的区块; - 两者都空则输出
(no output); - sandbox 拒绝时追加 deny 标记与 escalation 提示;
- 超时追加
[timed out after Nms],外部 kill 追加[stopped: reason]; - 信号致死追加
[killed by signal: X],否则非零退出码追加[exit code: N](收尾标记); - 某个流被截断时,在该流末尾追加
[output truncated; full output: 路径],路径不可用时写(unavailable)。
非零退出留在正文里(in-band),不抛异常——与 seam的划分保持一致。