GROIZ · grove

让 agent 的显式逻辑成为可审查的程序

Groiz 是一个治理学习运行平台。它的 agent 循环不是提示词的隐式编排,而是一段 真实执行的 Zio 程序:什么时候问模型、怎么对待执行失败、何时停止,都写在代码里,可以被读取、比较和改写。 这一页把这个循环拆开讲清楚,并让你亲手把它走一遍。

00循环总览

一次 Groiz agent 运行的完整回合。决定"下一步做什么"的判定逻辑跑在 Zio 里; 模型调用与候选执行是两个宿主绑定——宿主决定一次调用花多少预算、一段代码能碰到什么,程序自己无法扩大。

TASK
任务与预算
任务描述 + max-turns,上限由宿主给定
ASK
询问模型
harness-complete:任务 + 上一轮失败证据 → 程序候选
EXECUTE
隔离执行
harness-execute-zio:jail 里的 zio 进程,单帧 JSON 信封回报
REVISION
失败即证据
非 completed 的结果原样带回下一轮,不做摘要、不美化
STOP
候选或耗尽
candidate(执行成功)或 exhausted(到达 turn 上限)

01Playground:把循环走一遍

点"下一步"逐阶段推进,或"运行到底"看完整回合。每一阶段都会在右侧代码面板 高亮真正实现它的那一行——不是伪代码,是仓库里正在运行的源码。 模型由页面脚本模拟:第 1 轮故意返回一个有错的程序,第 2 轮修订——与仓库内合同测试的做法一致。

运行面板turn: 0
status: idle max-turns: 3
等待开始idle
点击「下一步」开始。任务:“计算 21 的两倍并打印结果”,预算 3 turns。
实现源码libs/loom/agent.zio
libs/loom/agent.zio — agent-runZio

02原理:判定逻辑与宿主能力的分离

循环只有一个调用点:Zio 决定何时问、问完怎么办、何时停; 宿主决定一次模型调用花多少预算、一段程序被允许碰到什么、跑多久。两件事在同一份源码里划得很清楚。

整个循环,就这一个函数

agent-run 是循环的全部控制流:带一个 turn 计数和一个 feedback 值递归(尾递归)。 每轮做三件事:问(harness-complete 拿到回复),执行(harness-execute-zio 跑真实进程), 判定(completed → candidate 结束;否则带着失败证据进下一轮)。

注意 max-turns 的来源——它是宿主传进来的上限,不是程序自己决定的数。 注释里写明了:就算这段代码无视这个参数,宿主也在独立计量调用次数。 循环自己没有能力给自己加轮次。

失败的反馈原样传递(feedback-of 不做摘要): 一轮修订如果只能看到失败原因的转述,那它是在猜。这是整个设计里最实用主义的一条。

libs/loom/agent.zio — Zio 库(Loom)循环判定
;; 循环本身。max-turns 是宿主授予的上限,不是这段
;; 代码决定的数:调用计数在宿主侧独立计量。
(defn agent-run [task max-turns]
  (loop [turn 0 feedback nil]
    (if (>= turn max-turns)
      {:status "exhausted"
       :turns turn
       :last feedback}
      (let* [reply (harness-complete task feedback)   ;; ① 问
             source (source-of reply)                ;; 无 :source 则非程序
             outcome (if (nil? source)
                       {:status "failed"
                        :error "the model returned no source to execute"}
                       (harness-execute-zio source))] ;; ② 执行
        (if (= (get outcome :status "failed") "completed")
          {:status "candidate"                       ;; ③ 成功即候选
           :turns (+ turn 1)
           :execution outcome}
          (recur (+ turn 1) (feedback-of outcome))))))) ;; ④ 失败回炉

(defn feedback-of [outcome]
  ;; 错误整条携带,不摘要:看到转述的修订是在猜。
  {:status  (get outcome :status "failed")
   :error   (get outcome :error "no error detail was reported")
   :turns   (get outcome :turns 0)})

两个宿主绑定:合同先行

harness-complete 与 harness-execute-zio 是循环不能自己完成的两件事。它们按调用方装配:

  • 启动器路径(tools/groiz run):Zio 写在 agent.zio 里——模型走 host/http-request 打 OpenAI 兼容端点;候选走 host/process-start 进 jail,以 zio --emit-framed 的单帧 JSON 回报。
  • Rust 路径(grove-app 的 AgentHost):绑定由 Rust 装配——模型走 Loom session(测试用脚本化 ModelHost),候选走 grove::execution 沙箱。合同测试不联网,回放证明宿主行为,模型能力由真实调用单独验收。

两个绑定都返回 keyword 键的 map——Zio 的 map 约定。用 string 键去读只会拿到默认值, 这正是一轮失败被误读成"没有错误"的原因,所以代码注释里专门写了这一条。

绑定入参返回
harness-complete(task feedback){:source "…"} 有程序;{:text "…"} 只是话
harness-execute-zio(source){:status "completed"|"failed" :error :output}

没有 :source 的回复不是程序。把它当程序执行,等于把空串递给沙箱、再报告沙箱对空串的看法——读起来像在评价模型写的代码,其实模型什么都没写。

agent.zio — Groiz 启动器路径宿主绑定
;; 模型一侧:OpenAI 兼容端点,启动器的网络授权。
(defn harness-complete [task feedback]
  (let* [provider (get agent--context :provider)
         reply (agent--chat provider
                 [{:role "user"
                   :content (agent--prompt task feedback)}])
         content (…choices[0].message.content…)]
    (if (nil? content)
      {:text "the provider returned no message content"}
      (let [source (agent--extract-source content)]
        (if (nil? source)
          {:text content}          ;; 说话,不是程序
          {:source source :text content})))))

;; 候选一侧:同一个 jail、同一个 NDJSON 合同。
(defn harness-execute-zio [source]
  (let* […staged (agent--stage-candidate run-dir source)…]
    (try
      (let [proc (host/process-start
                  {:executable zio-bin
                   :argv ["--emit-framed" "--step-limit" …
                          jail-path]
                   :cwd "/scratch" :scratch scratch
                   :read-only-mounts [{:source app-share …}]
                   :timeout-ms timeout-ms
                   :max-cpu-seconds 300 :max-processes 64})
             frame (agent--read-frame proc deadline)]
        (host/process-kill proc)
        ;; 信封 → outcome map:status/error/output
        (outcome-of frame))
      (catch any
        ;; macOS 没有 Linux namespaces:诚实拒绝,
        ;; 这个失败本身作为证据进入下一轮。
        {:status "failed" :error (str *error*) :output ""}))))

执行合同:一帧 JSON,一次诚实

候选进程的 stdout 只允许出现一帧 JSON——这是 --emit-framed 的信封, 候选自己的打印被捕获进 output 字段,帧流保持可解析。 status 由宿主根据真实结果计算:refused(拒绝执行)和 failed(跑失败)是两个不同的事实, 程序没有权利给自己命名一个体面的状态。

隔离不是可选的礼貌:候选进程起在 user/mount/pid namespace 的 jail 里, env -i 清空环境、scratch 是唯一可写点、其余只读挂载。 没有 namespace 的宿主(比如 macOS)会以 capability-denied 拒绝—— 诚实的拒绝比假装成功的模拟更有价值,因为它把边界缺口变成了一等证据。

真实执行的帧(2026-10-31 实跑记录)NDJSON
// 一轮候选执行在 stdout 上留下的全部东西:
{"error":"",
 "output":"noisy\n",          // 候选自己 println 的内容
 "output-truncated":false,
 "result":null,
 "status":"completed",         // 宿主计算的状态,非自报
 "steps":0}

// 循环对它的判定路径:
;; status == "completed"  →  {:status "candidate" …}
;; 其他(failed / refused) →  feedback 带回下一轮:
{:status "failed"
 :error "capability-denied: process isolation requires Linux namespaces"
 :turns 1}

为什么循环本身要是一门同像性语言的程序

Groiz 的论点:自学习系统的升级依据应该来自独立评价与人工审查,而不是模型对自己行为的文字解释。 前提是——agent 的显式逻辑必须是真实执行的程序,可以被读取、比较和改写。

同像性(代码即数据)提供的是这一步:agent-run 不是藏在框架里的隐式控制流, 它就是仓库里的一个 (defn …)。审查者可以 diff 两个版本的循环、 提议改写并自动实验——在授权范围内。可学习区、受治理区、受保护区在架构里是三个不可合并的区域; 正式发布默认人工批准,人工同意也不能绕过硬门槛。

换句话说:这一页教你的循环,本身就是 Groiz 第一个可被自己演化的对象。

main.zio — 循环的入口合同Zio
;; The bounded-agent entry the generic runner calls. Grove owns the
;; dispatch; Loom supplies the reusable loop, and the budget cap is a
;; keyword the host meters, not a number the candidate chose.
(defn agent-entry [task budget]
  (require :libs.loom.agent :refer [agent-run])
  (agent-run task (get budget :max_turns 0)))

;; CLI 面:grove run --task … --provider-config … --max-turns N
(defn grove--run-handler [parsed]
  (let* [opts (get parsed :options)
         launcher (get parsed :launcher {})
         task (get opts :task "")
         max-turns (get opts :max-turns 3)]
    (grove--require (not (= task "")) "invalid-input" "run needs --task")
    (load "agent.zio")
    (let [provider (agent--load-provider (get opts :provider-config ""))]
      (views--render-run-report
        (agent--run provider task max-turns launcher)))))