Groiz 是一个治理学习运行平台。它的 agent 循环不是提示词的隐式编排,而是一段 真实执行的 Zio 程序:什么时候问模型、怎么对待执行失败、何时停止,都写在代码里,可以被读取、比较和改写。 这一页把这个循环拆开讲清楚,并让你亲手把它走一遍。
一次 Groiz agent 运行的完整回合。决定"下一步做什么"的判定逻辑跑在 Zio 里; 模型调用与候选执行是两个宿主绑定——宿主决定一次调用花多少预算、一段代码能碰到什么,程序自己无法扩大。
max-turns,上限由宿主给定harness-complete:任务 + 上一轮失败证据 → 程序候选harness-execute-zio:jail 里的 zio 进程,单帧 JSON 信封回报candidate(执行成功)或 exhausted(到达 turn 上限)点"下一步"逐阶段推进,或"运行到底"看完整回合。每一阶段都会在右侧代码面板 高亮真正实现它的那一行——不是伪代码,是仓库里正在运行的源码。 模型由页面脚本模拟:第 1 轮故意返回一个有错的程序,第 2 轮修订——与仓库内合同测试的做法一致。
循环只有一个调用点:Zio 决定何时问、问完怎么办、何时停; 宿主决定一次模型调用花多少预算、一段程序被允许碰到什么、跑多久。两件事在同一份源码里划得很清楚。
agent-run 是循环的全部控制流:带一个 turn 计数和一个 feedback 值递归(尾递归)。
每轮做三件事:问(harness-complete 拿到回复),执行(harness-execute-zio 跑真实进程),
判定(completed → candidate 结束;否则带着失败证据进下一轮)。
注意 max-turns 的来源——它是宿主传进来的上限,不是程序自己决定的数。
注释里写明了:就算这段代码无视这个参数,宿主也在独立计量调用次数。
循环自己没有能力给自己加轮次。
失败的反馈原样传递(feedback-of 不做摘要):
一轮修订如果只能看到失败原因的转述,那它是在猜。这是整个设计里最实用主义的一条。
;; 循环本身。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 回报。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 的回复不是程序。把它当程序执行,等于把空串递给沙箱、再报告沙箱对空串的看法——读起来像在评价模型写的代码,其实模型什么都没写。
;; 模型一侧: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 ""}))))
候选进程的 stdout 只允许出现一帧 JSON——这是 --emit-framed 的信封,
候选自己的打印被捕获进 output 字段,帧流保持可解析。
status 由宿主根据真实结果计算:refused(拒绝执行)和 failed(跑失败)是两个不同的事实,
程序没有权利给自己命名一个体面的状态。
隔离不是可选的礼貌:候选进程起在 user/mount/pid namespace 的 jail 里,
env -i 清空环境、scratch 是唯一可写点、其余只读挂载。
没有 namespace 的宿主(比如 macOS)会以 capability-denied 拒绝——
诚实的拒绝比假装成功的模拟更有价值,因为它把边界缺口变成了一等证据。
// 一轮候选执行在 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 第一个可被自己演化的对象。
;; 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)))))