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进程当前目录默认工作目录
TimeoutMs120000默认前台超时
MaxTimeoutMs600000逐次覆盖的上限
MaxOutputBytes64000每流内存输出上限
MaxSpillBytes64 MiB每流溢写文件上限
GraceMs3000SIGTERM 到 SIGKILL 宽限
Executable无显式可执行文件,缺省则解析 bash / pwsh

pwsh 的发现顺序

Windows 上 pwsh 不一定在 PATH 里,所以有一条显式的发现链:

  1. 配置里给了显式路径就用它;
  2. Windows 上依次尝试 %ProgramFiles%\PowerShell\7\pwsh.exe、PATH 各条目下的 pwsh.exe、%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe,取第一个存在的;
  3. 都没有则回退到裸名字 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:

选项默认含义
EnableRunInBackgroundtrue广告 run_in_background 并跟随 registry 出现/消失切 schema
PromoteOnTimeouttrue前台超时是否把命令转后台 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 把结构化结果拍平成一段文本,规则是固定的:

  1. 先是 stdout;
  2. stderr 非空时追加一段带 [stderr] 标记的区块;
  3. 两者都空则输出 (no output);
  4. sandbox 拒绝时追加 deny 标记与 escalation 提示;
  5. 超时追加 [timed out after Nms],外部 kill 追加 [stopped: reason];
  6. 信号致死追加 [killed by signal: X],否则非零退出码追加 [exit code: N](收尾标记);
  7. 某个流被截断时,在该流末尾追加 [output truncated; full output: 路径],路径不可用时写 (unavailable)。

非零退出留在正文里(in-band),不抛异常——与 seam的划分保持一致。

下一步

在 GitHub 上编辑此页