Stage 01Day 5第 5 天 / 共 14 天

Day 5:Bash 工具 + 权限引擎

Day 4 让 Agent 能安全改文件。今天给它装上『跑命令』的能力——但每条命令都要先过一道权限闸门:危险命令直接拒、普通命令弹预览要你按 y、文件编辑可以切到 acceptEdits / plan 模式。模型只看到一个 bash 工具,所有策略都藏在 PermissionRequest → decide_permission 这条链路里。

Day 4 让 Agent 能安全改文件。每次写盘前 harness 出 diff、让你按 y/N、自动备份旧内容——一套完整的读写保护链。但 Agent 还不能跑命令。模型想跑个 pytest 看测试、跑个 git status 看仓库状态,现在没工具。Day 3 的 grep 是 Python 实现,不是让模型调 shell。

今天给 Agent 装两条新能力。一是 bash 工具:模型能调任意 shell 命令,但执行前要过一道权限引擎——危险命令拦截、命令预览确认、--permission-mode 三模式(default / acceptEdits / plan)。二是 ask_user_question 一等工具:模型不止能"做",还能"问你",弹一个 numbered menu 等你选。

代码约 300 行,新增 4 个文件(bash_runner.py / permissions.py / bg_manager.py / prompt_ui.py),改 3 个老文件(tools.py / agent.py / cli.py),删 1 个老文件(diff_ui.py,实现搬到 prompt_ui.py 后删除——v1 先留 re-export 垫片让老 import 还能跑,v2 切完再删)。无新依赖——subprocessthreadingre 全是标准库。


Day 5 主视觉:Bash + Permission Engine

先看这张 Agent Logic Map。它不做逐帧执行回放,只抓 Day 5 最容易混在一起的四条线:tool_use 如何变成 PermissionRequestbash 如何先过危险命令拦截和确认,--permission-mode 如何改变 allow / ask / deny,以及 ask_user_question 和后台 bash 为什么仍然属于 harness 分支。

加载 Agent Logic Map 中…

今天从 Day 4 的 agent-code 项目继续改。仓库里的 packages/day-04-safe-edit/ 是 Day 4 的参考答案快照(Day 5 终态参考 demo/agent_code/)。

起手:今天的起点

Day 4 给了 Agent 两个写工具——file_write 整文件覆盖、file_edit 字符串替换。harness 在落盘前会渲染 diff、等用户按 y、自动备份旧内容。但拦截块是 agent.py 里一段 if call.name in ("file_write", "file_edit"):——只对编辑生效,且每加一种新工具就要加一个分支。

今天围绕这个拦截块加四件事:

  • 新文件 bash_runner.py:用 subprocess.run 跑 shell 命令,cwd 锁项目目录、env 最小集合、超时 + 输出截断。
  • 新文件 permissions.pyPermissionRequest 描述一次工具调用,PermissionDecision(allow | ask | deny) 是 harness 的决策;decide_permission() 是统一入口。
  • 新文件 prompt_ui.py:从 Day 4 的 diff_ui.py 迁出 render_diff + confirm_edit,再加 confirm_command + confirm_tool_use + prompt_single_choicediff_ui.py 分两步处理:v1 改成从 prompt_ui re-export 的垫片让老 import 不动也能跑,v2 切完调用点后把 diff_ui.py 整个删掉。
  • 新文件 bg_manager.pysubprocess.Popen + daemon 线程,立刻返回 background_id,输出落到 .bg/<id>.out/.err

主线分四步:v1 先把 bash 跑通,v2 把确认逻辑抽成权限引擎,v3 加 ask_user_question,v4 接后台执行。


v1:bash 同步执行 + 命令预览 + y/N 确认

先把最直接的场景跑通:模型想跑一条命令,harness 把命令打印出来让你看过,你按 y 才执行。和 Day 4 v1 的 file_write 思路一样——拦截在 tools.run 之前,先预览再确认。但 bash 有个关键区别:命令越界检查靠的不是 resolve_in_cwd(命令是 shell 字符串,不是文件路径),而是 subprocess.run(cwd=ctx.cwd) 把进程锁在项目目录里。

1.1 新建 agent_code/bash_runner.py

bash 执行抽象成独立模块。工具函数只做薄包装,实际执行逻辑在 runner 里——这样权限引擎和工具函数都能复用它。完整 41 行看 v1 DiffCard,核心两块:

# bash 执行的环境变量最小集合——不让宿主机的敏感变量漏进子进程
_MINIMAL_ENV = {
    "PATH": os.environ.get("PATH", "/usr/bin:/bin"),
    "HOME": os.environ.get("HOME", ""),
    "USER": os.environ.get("USER", ""),
    "SHELL": os.environ.get("SHELL", "/bin/bash"),
}


def run_sync(command: str, cwd: Path, timeout: int = 30) -> str:
    """同步执行 shell 命令。cwd 锁定在项目目录,超时后杀进程。"""
    try:
        proc = subprocess.run(
            command,
            shell=True,
            cwd=str(cwd),
            env=_MINIMAL_ENV,
            capture_output=True,
            timeout=timeout,
        )
    except subprocess.TimeoutExpired:
        return f"error: command timed out after {timeout}s"
    ...
    truncated = truncate_output(output.strip(), max_chars=12000)
    if proc.returncode != 0:
        return f"exit code {proc.returncode}\n{truncated}"
    return truncated if truncated else "(no output)"

shell=True 是为了让模型写的命令和读者在终端里敲的一致。这个选择有安全代价——任何能绕过权限引擎的 shell 注入都可能直接执行。教学版的安全策略在 v2 的权限引擎里补:危险命令 regex 拦截 + 用户确认。

1.2 新建 agent_code/prompt_ui.py

Day 4 的 diff_ui.py 里只有 render_diffconfirm_edit。今天还要再加 confirm_command(v1 用)、confirm_tool_use(v2 用)、prompt_single_choice(v3 用)——里面 80% 都不是 diff 了,文件名得跟着改。把它搬成 prompt_ui.py

模块搬家分两步:v1 这一版把实现挪到 prompt_ui.pydiff_ui.py 改成 4 行 re-export 垫片,让 v1 的 agent.py(还在写 from .diff_ui import ...)原封不动也能跑。v2 改 agent.py 切到 from .prompt_ui import ...,切完整个包里没人再 import diff_ui,把它整个删掉。本版不一次到位是因为 v1 的目标是 bash 拦截块跑通,再顺手改 agent.py 那行 import 会让本版的 diff 多一处和 bash 无关的改动。

diff_ui.py 整个文件替换成下面这 4 行(原来的 import difflib / import typer / def render_diff / def confirm_edit 全删):

from __future__ import annotations

# Day 5 v1:render_diff 和 confirm_edit 的实现迁到了 prompt_ui.py。
# 这里只是个 re-export 垫片,让 v1 的 agent.py 不用改 import 也能跑。v2 会把它删掉。
from .prompt_ui import confirm_edit, render_diff

__all__ = ["confirm_edit", "render_diff"]

confirm_command 的函数体很短:

def confirm_command(command: str) -> bool:
    """让用户确认是否执行这条 bash 命令,默认不执行。"""
    return typer.confirm(f"Run this command?", default=False)

1.3 改 agent_code/tools.py:新增 bash、git_status、git_diff

三个新工具。bash 是核心——调 bash_runner.run_syncgit_statusgit_diff 是只读便利工具,内部也是 subprocess,但 v2 的权限引擎会把它们放进 _READONLY_TOOLS 白名单——降低 ask 弹窗噪音。

顶部 import 区追加:

from .bash_runner import run_sync as _bash_run_sync

file_edit 之后、class ToolRegistry 之前插入三个工具函数。bash 工具函数本身很薄:

def bash(args: dict[str, Any], ctx: ToolContext) -> str:
    """执行 shell 命令。前置校验和用户确认在 agent.py 拦截块完成。"""
    command = args.get("command", "")
    if not command:
        return "error: missing required argument 'command'"
    timeout = int(args.get("timeout", 30))
    background = bool(args.get("background", False))

    # v1 只做同步;v4 接 background=True 分支
    if background:
        return "error: background mode not implemented yet (coming in v4)"

    return _bash_run_sync(command, ctx.cwd, timeout=timeout)

完整三个工具函数 + 三处 registry.register(...) 看 v1 DiffCard。注意 bashinput_schema 只暴露 command / timeout / background 三个字段——危险命令拦截、用户确认、模式切换全都不暴露给模型。

1.4 改 agent_code/agent.py:在拦截块加 bash 分支

Day 4 的拦截块只认 file_writefile_edit。v1 在这个 if 后面加一个 elif 分支:识别 bash 工具,打印命令预览,让用户确认。顶部 import 追加 from .prompt_ui import confirm_command,然后在 result = tools.run(call, ctx) 之前插入:

            # bash 拦截:打印命令预览,让用户确认后再执行
            elif call.name == "bash":
                command = call.arguments.get("command", "")
                timeout = call.arguments.get("timeout", 30)
                console.print(f"\n[bold yellow]Command:[/bold yellow] {command}")
                console.print(f"[dim]timeout: {timeout}s  cwd: {ctx.cwd}[/dim]")
                if not confirm_command(command):
                    result = ToolResult(call.id, "error: command rejected by user", is_error=True)
                    emit(f"observation: {result.content}")
                    tool_result_blocks.append(...)
                    continue

注意这里只拦截了 bashgit_statusgit_diff 默认走 tools.run,不弹确认。

1.5 跑三个验证

(a) 只读 git 工具——不弹确认直接出结果:

$ uv run agent-code "用 git_status 看一下当前仓库状态,然后用 git_diff 看改了什么"
Agent Code
cwd: /your/project
provider: anthropic  model: deepseek-v4-flash

tool_call: git_status {}
observation: On branch main
Changes not staged for commit:
  modified: ...

tool_call: git_diff {}
observation: diff --git ...
final: 当前仓库有一些未提交改动,我已经看到了 git status git diff。

这里不要求你的输出和示例一模一样:如果你正在这个仓库里跟着教程改代码,git status 大概率不是 clean。关键看两件事:出现 tool_call: git_status / tool_call: git_diff,并且中间没有 Run this command? 提示。

(b) bash 执行——命令预览 + 确认:

$ uv run agent-code "用 bash 跑一下 pytest --version"
...
tool_call: bash {'command': 'pytest --version', 'timeout': 30, 'background': False}

Command: pytest --version
timeout: 30s  cwd: /your/project
Run this command? [y/N]: y
observation: pytest 8.x.x
final: 当前环境中安装了 pytest 8.x.x。

看到 Command: 黄字预览和 Run this command? 提示,按 y 才执行。按 n 会看到 observation: error: command rejected by user

(c) bash 执行报错——模型看到 exit code 后自我修正:

$ uv run agent-code "用 bash 跑一下 python -c 'print(1/0)'"
...
observation: exit code 1
Traceback (most recent call last):
  File "<string>", line 1, in <module>
ZeroDivisionError: division by zero
final: 命令执行失败了——代码尝试用 0 做除数,Python 抛出了 ZeroDivisionError。

模型拿到 exit code 1 + stderr 内容后能自己分析失败原因。

v1 的 bash 能跑了,但确认逻辑还嵌在 agent.py 的 if/elif 链里。每加一种新工具就要加一个分支,而且危险 bash 也只是弹 y/N,没有在确认前被拦住。下一版把确认逻辑抽成独立的权限引擎。

loading…

v2:权限引擎——三模式 + 危险命令拦截

v1 有三个问题:

  1. 确认逻辑散落file_write / file_edit 的 diff+confirm 在 if 块里,bash 的命令预览+confirm 在 elif 块里。加 ask_user_question 又要加一个分支。
  2. 危险 bash 只靠确认不够echo hellorm -rf / 都走同一个 y/N 流程。危险命令应该在确认前直接拒绝。
  3. 没有模式切换:有时候你想让 Agent 自己改文件不弹确认(acceptEdits),有时候你想让它只看不改(plan)。

权限引擎解决这三个问题:所有工具调用在执行前都经过 decide_permission(),返回 allow | ask | deny。Agent Loop 根据决策分发:allow → 直接执行,ask → 渲染预览 + 用户确认,deny → 返回 error observation

2.1 新建 agent_code/permissions.py

核心是一个函数 decide_permission(request)PermissionRequest 描述"这次工具调用想做什么",PermissionDecision 描述 harness 要不要放行。完整 93 行看 v2 DiffCard,关键 dataclass + 决策函数骨架:

@dataclass
class PermissionRequest:
    """一次工具调用的权限请求。工具只描述意图,是否执行交给 harness 决定。"""
    tool_name: str
    args: dict
    mode: str
    cwd: Path


@dataclass
class PermissionDecision:
    """权限引擎的决策结果。behavior 是 allow / ask / deny 之一。"""
    behavior: str  # "allow" | "ask" | "deny"
    message: str | None = None


def decide_permission(request: PermissionRequest) -> PermissionDecision:
    """权限引擎入口:根据工具名、参数和当前模式决定 allow / ask / deny。"""
    tool_name = request.tool_name
    args = request.args
    mode = request.mode

    if tool_name in _ASK_TOOLS:
        return PermissionDecision("ask")

    # plan 模式:只允许只读工具。写类工具一律 deny。
    if mode == "plan":
        if tool_name in _READONLY_TOOLS:
            return PermissionDecision("allow")
        return PermissionDecision("deny", f"plan mode: {tool_name} is not allowed. ...")

    # 只读工具在所有模式下默认允许
    if tool_name in _READONLY_TOOLS:
        return PermissionDecision("allow")

    # bash 工具的危险命令检测——不管什么模式,危险命令先拦截
    if tool_name == "bash":
        command = args.get("command", "")
        danger_reason = _is_dangerous(command)
        if danger_reason:
            return PermissionDecision("deny", f"Dangerous command blocked: {danger_reason}")

    # acceptEdits 模式:文件编辑跳过确认 UI,但安全校验仍在 agent.py 做
    if mode == "acceptEdits":
        if tool_name in ("file_write", "file_edit"):
            return PermissionDecision("allow")

    # 默认模式:写文件和 bash 需要确认
    return PermissionDecision("ask")

危险命令 regex 是教学版的最小安全网,覆盖 rm -rf / sudo / chmod -R / curl ... | sh / git push --force / git push -f / git push / git reset --hard 等。_READONLY_TOOLSread_file / list_files / glob / grep / project_tree / git_status / git_diff / system_date / echo 一并放进白名单,默认放行。_ASK_TOOLSask_user_question / web_fetch / web_search——这些工具即使是只读,也希望让用户知道 Agent 在停下来问人或访问外部资源。

为什么只读工具白名单放在权限引擎而不是工具注册表?因为"是否只读"是权限决策的属性,不是工具自身的属性。同一个 bash 工具可能跑只读命令也可能跑写命令,权限引擎看的是"这个工具在当前调用里做什么"。

2.2 改 agent_code/agent.py:用 decide_permission 统一拦截

把 v1 的 if/elif 拦截链替换成统一的 permission gate。顶部 import 把 confirm_command/confirm_edit/confirm_tool_use/render_diff 都从 prompt_ui 直接导入,并追加 from .permissions import PermissionRequest, decide_permission。切完之后整个 agent_code/ 没人再 import diff_ui——rm agent_code/diff_ui.py 把 v1 留的过渡垫片删掉。可以 rg 'from \.diff_ui|import diff_ui' agent_code/ 验证没遗留引用。垫片用完就拆,留着是死代码。

run_agent 签名加 permission_mode: str = "default" 参数。然后把整个工具调用循环里的 if/elif 拦截块替换成三分支结构。核心变化:所有工具调用先包装成 PermissionRequestdecide_permission,根据返回的 behavior 分发到 deny / ask / allow 三条路径:

            # 权限引擎统一入口:所有工具调用先包装成 PermissionRequest
            request = PermissionRequest(
                tool_name=call.name,
                args=call.arguments,
                mode=permission_mode,
                cwd=ctx.cwd,
            )
            decision = decide_permission(request)

            edit_preview: tuple[str, str, str] | None = None
            if call.name in ("file_write", "file_edit") and decision.behavior != "deny":
                # acceptEdits 只跳过确认 UI,不能跳过 Day 4 的安全校验
                # 路径解析 + read-before-edit + mtime 检查 + apply_single_replace 全在这里跑
                ...
                edit_preview = (path_str, old_content, new_content)

            if decision.behavior == "deny":
                # deny 路径:直接返回 error observation,不弹 UI
                result = ToolResult(call.id, f"error: {decision.message}", is_error=True)
                ...
                continue

            elif decision.behavior == "ask":
                if call.name in ("file_write", "file_edit"):
                    # ask 模式只负责 diff + confirm(校验已经做过)
                    ...
                elif call.name == "bash":
                    # 命令预览 + confirm
                    ...
                elif call.name in ("web_fetch", "web_search"):
                    # confirm_tool_use 让用户确认访问外部资源
                    ...
                elif call.name == "ask_user_question":
                    pass  # v3 接上

            # allow 路径 + ask 通过:执行工具
            result = tools.run(call, ctx)

三种决策路径:deny 直接返回 error observation(不弹 UI、不执行工具);ask 按工具类型分发不同的预览 UI,用户确认后才落到 tools.runallow 跳过 UI 直接 tools.run。完整 ~150 行替换看 v2 DiffCard。

注意 acceptEdits 只是跳过确认,不是跳过安全校验file_write / file_edit 在进入 askallow 分支前,都会先走 Day 4 的 ensure_read_before_edit + check_mtime_conflict + apply_single_replace 预计算。

2.3 改 agent_code/cli.py:加 --permission-mode 选项

main_command 签名加:

    permission_mode: str = typer.Option("default", "--permission-mode", help="Permission mode: default, acceptEdits, plan"),

run_once 签名加 permission_mode: str 参数,并在两处 run_once(...) 调用(直接 run 和 REPL 循环)传过去;最后传给 run_agent(..., permission_mode=permission_mode)

2.4 跑五个验证

(a) default 模式——文件编辑仍需确认:

Day 4 跑完后 hello.txt 通常已经被改成了 hola from agent。先让模型读一遍再把 hola 改回 hello

$ uv run agent-code "先读 hello.txt,再把里面的 hola 改成 hello"
...
tool_call: read_file {'path': 'hello.txt'}
observation: hola from agent
tool_call: file_edit {...}

Diff for hello.txt:
...
Apply this edit to hello.txt? [y/N]:

Day 4 的 diff + confirm 行为不变。

(b) acceptEdits 模式——文件编辑跳过确认:

$ uv run agent-code --permission-mode acceptEdits "先读 hello.txt,再把里面的 hello 改成 hola"
...
tool_call: file_edit {...}
observation: Edited hello.txt: replaced 5 chars with 4 chars

没有 Diff forApply this edit?——acceptEdits 模式下 decide_permissionfile_write / file_edit 返回 allow,但执行前仍会跑 read-before-edit、mtime 冲突和字符串替换校验。bash 仍然需要确认:

$ uv run agent-code --permission-mode acceptEdits "用 bash 跑 echo hello"
...
Command: echo hello
Run this command? [y/N]:

(c) plan 模式——写工具被拒绝:

$ uv run agent-code --permission-mode plan "创建 hello.txt 文件"
...
tool_call: file_write {'file_path': 'hello.txt', 'content': 'hello'}
observation: error: plan mode: file_write is not allowed. Only read-only tools can run in plan mode.
final: 当前处于 plan 模式,不允许执行写入操作。

只读工具不受影响——read_file_READONLY_TOOLS 白名单里:

$ uv run agent-code --permission-mode plan "读一下 hello.txt"
...
tool_call: read_file {'path': 'hello.txt'}
observation: hola from agent

(d) 危险 git 命令被拦截:

$ uv run agent-code "用 bash 跑 git push --force origin main"
...
tool_call: bash {'command': 'git push --force origin main', 'timeout': 30, 'background': False}
observation: error: Dangerous command blocked: git push --force overwrites remote history

没有弹 Command 预览、没有弹确认——decide_permission_is_dangerous 那一步就返回了 PermissionDecision("deny")

(e) sudo rm -rf / 可能被模型先拒绝:

真实模型有时会在调用工具前就拒绝这类极端危险命令,给个 final 说"我不能运行"。这不代表权限引擎没生效,只是模型层先拦了一次。要稳定验证权限引擎,优先用上面的 git push --force

到这里,权限引擎的三模式 + 危险命令拦截全部接好。agent.py 的拦截逻辑不再是一堆 if/elif,而是一个统一的 decision.behavior 三分支。

loading…

v3:ask_user_question —— 让模型主动问你

v2 的权限引擎只处理了两种交互:allow(直接执行)和 ask(预览 + y/N)。但有些场景模型需要的不是"同不同意执行",而是让你做选择题——"先修测试还是先修代码?"

ask_user_question 就是干这个的。它是独立的一等工具,不是 slash 命令。模型调它,harness 阻塞 Agent Loop,终端弹一个 numbered menu 让你选,结果作为 tool_result 回灌模型。

3.1 改 agent_code/prompt_ui.py:加选项菜单

文件末尾追加:

def prompt_single_choice(question: str, labels: list[str]) -> str | None:
    """展示一个 numbered menu 让用户单选,返回被选中的 label。"""
    import rich
    console = rich.console.Console()
    console.print(f"\n[bold yellow]? {question}[/bold yellow]")
    for i, label in enumerate(labels, 1):
        console.print(f"  {i}. {label}")
    console.print(f"  0. [dim]Skip / Other[/dim]")

    try:
        choice = typer.prompt("Choice", default="0")
        idx = int(choice)
        if 1 <= idx <= len(labels):
            return labels[idx - 1]
        return None
    except (ValueError, TypeError):
        return None

3.2 改 agent_code/tools.py:新增 ask_user_question

工具函数本身不读 stdin——实际交互在 agent.py 拦截块里完成。这里只做参数校验,正常路径不会被调用:

def _ask_user_question(args: dict[str, Any], ctx: ToolContext) -> str:
    """由 agent.py 拦截块处理——工具函数本身不读 stdin。"""
    prompt = args.get("prompt", "")
    options = args.get("options", [])
    if not prompt:
        return "error: missing required argument 'prompt'"
    if not options or not isinstance(options, list):
        return "error: options must be a non-empty list"
    return "error: ask_user_question must be handled by the harness, not executed directly"

default_tools() 在 bash 注册之后追加 ask_user_question 注册(input_schemaprompt 字符串 + options 数组)。完整注册看 v3 DiffCard。

3.3 改 agent_code/agent.py:拦截 ask_user_question

v2 拦截块里有 elif call.name == "ask_user_question": pass 占位。顶部 import 追加 prompt_single_choice,把 pass 替换成实际逻辑:

                elif call.name == "ask_user_question":
                    question = call.arguments.get("prompt", "")
                    options = call.arguments.get("options", [])
                    if not isinstance(options, list):
                        options = []
                    labels = [str(o) for o in options]
                    selected = prompt_single_choice(question, labels)
                    if selected is None:
                        result = ToolResult(call.id, "User skipped the question.", is_error=False)
                    else:
                        result = ToolResult(call.id, f'User selected: "{selected}"', is_error=False)
                    emit(f"observation: {result.content}")
                    tool_result_blocks.append({
                        "type": "tool_result",
                        "tool_use_id": result.tool_call_id,
                        "content": result.content,
                        "is_error": result.is_error,
                    })
                    continue

注意 ask_user_question 走的是 continue——它自己构造 ToolResult 并加入 tool_result_blocks不落到后面的 tools.run。这和 file_write / file_edit / bash 不同:那些工具在确认通过后仍然需要 tools.run 执行实际动作;ask_user_question 的全部作用就是弹菜单拿答案。

3.4 跑验证

$ uv run agent-code "我应该先修测试还是先修代码?用 ask_user_question 问我,选项三个:先修测试、先修代码、不确定"
...
tool_call: ask_user_question {'prompt': '修复这个 bug 应该从哪个方向入手?', 'options': ['先修测试', '先修代码', '不确定']}

? 修复这个 bug 应该从哪个方向入手?
  1. 先修测试
  2. 先修代码
  3. 不确定
  0. Skip / Other
Choice: 2
observation: User selected: "先修代码"
final: 你选择了"先修代码"。那我们从修改实现代码开始……

模型拿到选择后继续推理。选 0 则返回 User skipped the question.——is_error=False,模型知道"用户看了问题但没给明确方向"。

v3 的 ask_user_question 到这里就收住。现在这个 numbered menu 是教学版的最小实现:能证明"模型发起结构化问题 → harness 暂停 loop 等用户 → 用户选择变成 tool_result → 模型继续推理"这条链路已经接通。

你在 Claude Code 里看到的上下键选择,本质上不是模型能力更强,而是 prompt_ui 这一层做得更完整:终端进入 raw input,监听上/下箭头移动当前选项,按 Enter 返回选中的 label,再由 Agent Loop 包成 tool_result。源码里这块也是一等工具,名字是 AskUserQuestion,它的权限检查固定走 ask,工具调用会要求用户交互;UI 侧支持一组问题、选项描述、预览和多选。我们今天不做这些,是为了先把 harness 边界讲清楚。后面真要升级,主要改 prompt_single_choice():把 typer.prompt("Choice") 换成上下键菜单,agent.py 的拦截流程基本不用变。

loading…

收尾:bash(background=True) —— 后台执行

v1 的 bash 是同步的:模型调了 bash("sleep 30"),Agent Loop 就得干等 30 秒。对编译、长时间测试这类场景,等不起。

后台执行解决这个问题:模型设置 background=True,harness 启动子进程后立即返回结构化 observation(含 background_id、输出文件路径、pid),Agent Loop 不阻塞。模型后续可以 bash("cat .bg/<id>.out") 查输出、bash("kill <pid>") 杀进程。

4.1 新建 agent_code/bg_manager.py

一个子线程跑 subprocess.Popen,stdout 和 stderr 分别写到 .bg/<id>.out.bg/<id>.err。启动函数立即返回 dict,不等待进程结束:

def start_background(command: str, cwd: Path) -> dict:
    """在后台启动 shell 命令,stdout/stderr 流式写入 .bg/<id>.out/.err。
    立即返回结构化信息,不等待命令结束。"""
    bg_id = f"bg-{uuid.uuid4().hex[:8]}"
    bg_dir = cwd / ".bg"
    bg_dir.mkdir(parents=True, exist_ok=True)
    out_path = bg_dir / f"{bg_id}.out"
    err_path = bg_dir / f"{bg_id}.err"

    out_f = open(str(out_path), "w")
    err_f = open(str(err_path), "w")

    proc = subprocess.Popen(
        command, shell=True, cwd=str(cwd), env=_MINIMAL_ENV,
        stdout=out_f, stderr=err_f,
    )

    def _wait_and_close() -> None:
        """等子进程结束,关闭文件描述符。在 daemon 线程里跑。"""
        proc.wait()
        out_f.close()
        err_f.close()

    t = threading.Thread(target=_wait_and_close, daemon=True)
    t.start()

    return {"background_id": bg_id, "output_file": ..., "stderr_file": ..., "pid": proc.pid, "message": ...}

daemon=True 的线程在主进程退出时自动回收,不会阻止 CLI 退出。这里先补一个完整心智模型:真正的后台任务生命周期不是"起一个线程"就结束了,而是 创建任务 → 记录 task id → 持续写输出 → 允许后续读取 → 支持取消 → 结束时通知模型 这一整条链。

Claude Code 的做法也是这个形状:后台 bash 仍然是同一个 Bash 工具,只是参数里带 run_in_background: true。harness 启动子进程后,会注册一个本地 shell task,把输出持续写到任务 output 文件,立刻把 backgroundTaskId 和输出路径作为 tool_result 还给模型。任务结束时,再通过 notification 把"这个后台任务完成了"这类消息送回模型上下文;用户要停掉任务时,走内部的 task kill 能力,而不是让模型自己猜 pid。

Day 5 不做这套完整管理。我们只保留最小链路:background=True 启动子进程,stdout/stderr 落到 .bg/<id>.out/.err,tool_result 返回 background_id、输出路径和 pid。后续模型想看结果,就自己 bash("cat .bg/<id>.out");想停掉,就自己 bash("kill <pid>")。按当前 14 天主线,bg_status / bg_read / bg_cancel 和完成 notification 不进主线,适合放到 extras,或者以后让 AI 按这张生命周期图补出来。

4.2 改 agent_code/tools.py:bash 接 background 分支

bash 工具函数里的:

    if background:
        return "error: background mode not implemented yet (coming in v4)"

替换成:

    if background:
        # 后台执行:启动子进程后立即返回结构化信息,不阻塞 Agent Loop
        from .bg_manager import start_background
        result = start_background(command, ctx.cwd)
        return (
            f"Command running in background with ID: {result['background_id']}.\n"
            f"Output is being written to: {result['output_file']}\n"
            f"Stderr is being written to: {result['stderr_file']}\n"
            f"PID: {result['pid']}\n\n"
            f"{result['message']}"
        )

后台模式不做 timeout(由子进程自己控制),也不做输出截断(输出落到文件,不在 tool_result 里)。

4.3 agent.py 不动

后台 bash 和同步 bash 走同一个 decide_permission 流程——对权限引擎来说,bash 就是 bash,不区分同步和后台。危险命令检测对两者都适用,用户确认对后台 bash 同样弹。background=True 只是执行方式不同,不是权限豁免。

4.4 跑两个验证

(a) 后台执行 + 稍后查输出:

$ uv run agent-code "用 bash(background=True) 运行 sleep 5 && echo 'done from background'。拿到 background_id 后,用同步 bash 跑 sleep 6 && cat 对应的 .bg 输出文件"
...
tool_call: bash {'command': 'sleep 5 && echo done from background', 'timeout': 30, 'background': True}

Command: sleep 5 && echo done from background
Run this command? [y/N]: y
observation: Command running in background with ID: bg-a1b2c3d4.
Output is being written to: .bg/bg-a1b2c3d4.out
...
tool_call: bash {'command': 'sleep 6 && cat .bg/bg-a1b2c3d4.out', 'timeout': 10, 'background': False}

Command: sleep 6 && cat .bg/bg-a1b2c3d4.out
Run this command? [y/N]: y
observation: done from background
final: 后台任务执行完成,输出为 "done from background"。

这里故意等 6 秒,因为刚启动后台任务就 cat 可能读到空文件。如果你用 printf 模拟交互,至少准备两次 y:一次给后台命令,一次给后面的 sleep 6 && cat ...

(b) 杀后台进程:

$ uv run agent-code "用 bash(background=True) 运行 sleep 300,然后用 kill 杀掉它"
...
observation: Command running in background with ID: bg-e5f6g7h8.
PID: 12345
...
tool_call: bash {'command': 'kill 12345', 'timeout': 10, 'background': False}

模型从第一次 tool_result 拿到 pid,构造成 kill <pid> 命令。

loading…

终端 Replay 演示

下面是 agent-code "用 bash 跑一下 pytest --version" 的终端动画,对应 Day 5 主视觉里的 7 帧故事——tool_call: bashCommand: 黄字预览 + timeout/cwd dim 行 → Run this command? [y/N]: yobservation: pytest 8.4.2final

加载终端记录中…

今天有了什么

  • bash 工具:模型能跑任意 shell 命令。cwd 锁定在项目目录,env 用 _MINIMAL_ENV 最小集合,timeout 默认 30s,输出截断到 12000 字符。git_status / git_diff 是它的只读薄包装,默认 allow,降低 ask 弹窗噪音。
  • 权限引擎 permissions.pyPermissionRequest 描述一次工具调用,PermissionDecision(allow | ask | deny) 统一决策。三模式——default(写类默认 ask)、acceptEdits(文件编辑跳过确认但保留 Day 4 安全校验)、plan(写类一律 deny)。
  • 危险命令拦截:regex 覆盖 rm -rf / sudo / chmod -R / curl | sh / git push / git push --force / git reset --hard。命中直接 deny,不弹 UI。
  • ask_user_question 工具:模型主动停下来问用户结构化单选问题。终端弹 numbered menu,结果作为 tool_result 回灌驱动下一轮推理。
  • 后台 bashbash(background=True) 启动子进程后立即返回 background_id + 输出文件路径。Agent Loop 不阻塞。模型后续用 bash("cat .bg/<id>.out") 查输出、bash("kill <pid>") 杀进程。
  • agent.py 拦截块统一化:v1 的 if/elif 链 → v2 的 decide_permission 三分支。每加一种新工具,不再要改拦截块结构——只要在权限引擎里给它一条决策规则。

常见问题

bash 工具报 command timed out after 30s

模型给的命令跑了超过 30 秒。默认 timeout 是 30s,可以让模型在调用时传更大的 timeout 值,或者改用 background=True 在后台跑。给 timeout 设上限可以放课后挑战。

shell=True 安全吗

教学版用 shell=True 是为了命令写法自然(和终端敲的一致)。安全靠两层:权限引擎的危险命令 regex 拦截(v2)+ 用户确认(v1)。生产环境还需要容器 sandbox、只读文件系统、网络隔离。这不是"shell=True 安全",而是"shell=True + harness 拦截"。

plan 模式下模型反复调 file_edit 被拒绝

模型可能没注意到自己处于 plan 模式。plan 模式在 system prompt 里应该有明确提示(Day 8 会加上)。目前如果模型陷入"调写工具 → 被 deny → 再调写工具"循环,可以 /exit 退出后用 --permission-mode default 重跑。

ask_user_question 选了 0 之后模型怎么处理

返回 User skipped the question.is_error=False。模型拿到这个结果一般会自己拍板或换种方式问你——它知道"用户看了问题但没给明确方向",不是 error。

后台 bash 的输出文件什么时候出现

start_background 调用后 .bg/<id>.out 文件立刻创建(Popenopen("w"))。但内容要等到子进程真正产生输出时才写入。模型刚拿到 background_id 立刻 cat 可能看到空文件——让它等几秒再读。教程验证里用的是 bash("sleep 6 && cat .bg/<id>.out")


课后挑战

  • 挑战 1:可选 range 的时间限制。给 permissions.py 加一个配置项 _MAX_TIMEOUT = 120,在 bash 分支里检查 timeout > _MAX_TIMEOUT,超过则返回 ask 并附加说明;给命令行加 --max-timeout 选项。
  • 挑战 2:记住用户选择。在 permissions.py 里加一个 _remembered: dict[str, str],用户对某个工具选了 "always allow" 后后续直接 allow;给 confirm_commandconfirm_edit 加 "yes to all" 选项。
  • 挑战 3ask_user_question 支持多选。给 prompt_single_choice 加一个 multi_select 参数,多选模式下输入 1,3 返回两个 label;同步改 _ask_user_question 的 schema 加 multi_select 字段。
  • 挑战 4:后台任务列表。给 bg_manager.py 加一个 list_background() -> list[dict],扫描 .bg/ 目录返回所有后台任务的 status(running / done);给 tools.py 加一个 bg_list 工具让模型列出自己的后台任务。
  • 挑战 5--permission-mode 在 REPL 里切换。给 CLI 加一个 /permissions slash 命令,在 REPL 里输入 /permissions acceptEdits 切换当前会话的权限模式,不需要退出重进。

思考题

几个开放性问题,先自己憋一句话答案,再继续往下看。面试官真问起 harness 边界这块,能不能讲清楚就看这关。

  1. 为什么 decide_permission 放在 agent.py 的拦截块里调,而不是放在 tools.py 的每个工具函数内部? (提示:和 Day 4 思考题 2 共享同一个设计原则。如果一个工具函数自己决定"要不要弹确认",把 CLI 换成 web 端时会发生什么?)

  2. git_statusgit_diff 被做成独立工具,而不是让模型调 bash("git status")。这个设计选择的好处和代价分别是什么? (提示:想一下 _READONLY_TOOLS 白名单是怎么工作的,以及模型在猜测"什么命令是只读的"时可能犯什么错。)

  3. plan 模式只做了"写工具一律 deny"的硬约束,但没做"强制先用 todo_write 起草计划"的软约束。少了软约束,plan 模式还能叫 Plan Mode 吗? (提示:Day 8 会补上这一半。先想想如果只有硬约束没有软约束,模型在 plan 模式下的典型行为会是什么。)

  4. 后台 bash 的输出写到 .bg/<id>.out 文件,模型通过 bash("cat .bg/<id>.out") 查询。如果不用文件、改用 harness 在进程结束时自动把输出作为一条 notification 注入下一轮 messages,Agent 的行为会有什么不同? (提示:notification 注入意味着模型不需要主动查询,但什么时候注入、注入给哪个 step 的模型?)


下一天

今天 Agent 具备了命令执行能力:能跑命令、有权限控制、能后台执行、能主动问你问题。加上 Day 3 的文件 + Web 工具和 Day 4 的安全编辑,Agent 已经具备了做小型代码任务的基础能力:读代码 → 改代码 → 跑测试 → 看结果 → 再改。

但每次 agent-code 退出后,对话历史就丢了。下一天我们要让会话可以保存和恢复——session JSONL 落盘、--resume 恢复上次对话、AGENT.md 项目记忆注入 system prompt、以及一个跨 session 的长期记忆系统(memdir)。这样 Agent 就能记住你和你的项目,而不是每次都是"初次见面"。