NovaCode 终端:编辑 README.md 前弹出确认,选项是允许、本次模式不再询问、拒绝

终端 AI 编码助手

NovaCode

运行在终端中的 AI 编程助手,通过自然语言读写代码、执行命令,并管理权限与长会话。

  • Python
  • Textual
  • MCP
  • OS Sandbox
02 / 05 · DOCUMENTATION

ReAct 与工具生命周期

将模型文本、完整工具参数、授权和完成事件接成可取消的异步执行循环。

先归一化模型事件

一次编程任务会在“模型决定下一步”和“工具产生环境反馈”之间反复切换。读取文件后再修改、命令失败后调整参数、权限拒绝后换成只读检查,都需要运行时把结果重新交回模型,而不是在界面里固定写死执行步骤。

不同模型协议对文本增量、工具参数和停止原因的表达不同。客户端把它们归一化为运行时可消费的事件,Agent 主循环据此生成文本、工具调用、工具结果、用量、错误与完成事件,界面无需复制各协议的判断。

事件中工具 ID 与工具名需要一起保留,失败结果也要带回原调用身份,否则对话历史中可能出现“有结果但找不到调用”的孤立记录。

事件类别运行时需要保留的信息用途
文本增量内容与当前运行连续显示模型输出
工具调用工具 ID、名称、完整参数建立执行与结果的关联
权限请求工具信息、审批身份与理由将决定交给授权路径
工具结果原调用 ID、输出、错误与耗时回写历史并更新工具状态
错误与完成当前运行、停止原因与用量确定本轮终止和后续处理

模型参数通常分多次传来,只有完整调用才能执行。文本已经出现工具名或 JSON 前缀,不代表参数模型已可校验;过早启动会让工具接收到半份参数,也会让审批内容与最终执行内容不一致。

边生成,边调度

完整工具参数到达后先经过统一授权。允许的工具可以交给 StreamingExecutor,由 asyncio.create_task 并行推进;还需要审批的调用不能因调度提前而绕过确认。

工具任务最终按提交顺序收集结果,完成的先后顺序不应打乱历史配对。普通工具异常转为带身份的错误结果;取消异常继续向上传播,不能把取消伪装成正常结果。

sequenceDiagram
  participant M as 模型流
  participant A as Agent
  participant P as 授权管线
  participant T as 工具任务
  M->>A: 完整工具调用 A
  A->>P: 解析工具、Hook 与权限检查
  P-->>A: 允许
  A->>T: 创建异步任务 A
  M->>A: 后续文本或工具调用
  T-->>A: 结果 A
  A->>A: 按提交顺序整理结果与历史
  A->>M: 下一次模型调用

这里的并发是调用已提交后的执行重叠,不是运行时自动证明不同写入互不冲突。若两个工具操作同一文件,仍需要任务设计、工具语义或 worktree 隔离避免竞争。模型一次提出多个调用,也不构成跨工具事务。

授权与执行只走一条管线

运行时先检查工具是否存在、是否启用,再运行 pre_tool_use Hook 和权限判断。未知工具、禁用工具和 Hook 拒绝都会形成明确结果;获得允许后,才验证参数模型并调用工具。

参数校验失败不算已执行,不触发执行后的 Hook;真正发起执行后,无论正常输出或普通工具错误,都会走恢复快照与后置 Hook 的处理。把这些分支集中起来,可以避免流式工具、延后审批工具与普通执行分支各自实现一套略有差异的规则。

错误结果使模型有机会修正调用,但不保证一定能自行解决。参数错误、权限拒绝、外部命令失败应分别呈现原因,不能都简化为“运行失败,请重试”。

取消必须等待收尾

取消界面上的加载状态,不代表工具已经停止。生成器关闭、异常、外层任务取消以及模型输出达到上限后的续跑路径,都需要清理已创建的子任务。

novacode/novacode/agent.pyL321-334 @8484bf0
async def cancel_and_wait(self) -> None:
    """取消所有子任务并等待回收,然后清空任务列表。

    每个退出路径(正常完成、max_tokens continue、异常、外层取消、
    生成器 aclose)都必须在 finally 里调用它,避免工具任务在
    「取消」之后继续跑、副作用落地。
    """
    tasks = [task for _, task, _, _ in self._tasks]
    self._tasks.clear()
    for task in tasks:
        if not task.done():
            task.cancel()
    if tasks:
        await asyncio.gather(*tasks, return_exceptions=True)
先取消未完成任务,再 await 回收;不会留下无人持有的执行任务。

finally 回收让运行时能够知道本轮已结束,但无法撤销工具已经完成的外部副作用。需要恢复的文件操作仍应依靠版本控制或项目的文件历史机制,不能把“可取消”描述成任意操作都可回滚。

清理不仅覆盖用户主动点停止,也覆盖流生成器关闭、模型输出上限后的续跑、工具异常和调用方取消。cancel() 发出的是取消请求,随后 await gather(...) 才能回收任务的终止结果;不等待收尾就开启下一轮,可能留下前一轮仍在改变文件的任务。

这也解释了为什么 CancelledError 必须向上传播。把它包成普通工具错误后继续循环,会让用户以为已经停止的任务再次触发模型或工具调用。

Remote 的关联约束

Remote 服务为本轮维护 run 身份及待审批 Future,只有权限 ID 和运行身份都匹配才接受响应。取消后的旧响应被忽略,防止上一轮允许写入的点击落到新一轮请求上。

novacode/novacode/remote.pyL933-967 @8484bf0
def _handle_permission_response(self, data: dict[str, Any]) -> None:
    """处理来自 Web UI 的权限回复。

    回复必须命中当前 run 的 pending future:perm id 或 run id 对不上
    (过期 run 的回复)一律丢弃,绝不落到新一轮的 future 上。
    """
    perm_id = data.get("id", "")
    response_str = data.get("response", "deny")

    future = self._pending_perms.get(perm_id)
    if future is None:
        return
    if future.done():
        self._pending_perms.pop(perm_id, None)
        self._pending_perm_runs.pop(perm_id, None)
        return

    perm_run_id = self._pending_perm_runs.get(perm_id, "")
    requested_run_id = data.get("runId") or self._run_id
    if perm_run_id != self._run_id or perm_run_id != requested_run_id:
        return

    self._pending_perms.pop(perm_id, None)
    self._pending_perm_runs.pop(perm_id, None)

    # 映射字符串到枚举
    mapping = {
        "allow": PermissionResponse.ALLOW,
        "deny": PermissionResponse.DENY,
        "allowAlways": PermissionResponse.ALLOW_ALWAYS,
    }
    response = mapping.get(response_str, PermissionResponse.DENY)
    # future 可能已经结束(本轮被取消时会被 cancel),此时不能再回填结果
    if not future.done():
        future.set_result(response)
审批属于具体运行,不能只凭“当前有一个待审批框”就填入结果。

本站 mock 用独立的运行对象与审批 ID 保留同一不变量:取消后再响应旧审批不写入虚拟文件;后续仍能开始新任务。它不实现真实 Python 任务、系统进程和网络工具的取消。

检查这一层时看什么

首先检查工具调用与结果是否一一对应,再检查拒绝是否确实没有执行,最后检查停止后是否回收当前任务且能开始新轮。文本生成成功不能替代这三项验证,因为界面可正常显示文字时,工具历史或后台任务仍可能已经不一致。