# s21: Goal Loop — 什么时候停,目标说了算,不是模型说了算 [English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md) s01 → ... → s19 → s20 → `s21` > *"一轮能不能结束,看目标条件满不满足,不是模型说停就停"* — `/goal` 在主循环每轮收尾的地方加一道闸门:每轮结束后,一个独立的判断器看可信证据够不够,不够就把模型推回去再来一轮。 > > **Harness 层**: 目标闭环 — 在轮次收尾处,加一道程序控制的完成闸门。 --- 从 s01 到 s20,一轮对话怎么结束?模型不再发 `tool_use`,循环就直接 `return` 了。一次性任务这么干没问题,做完就停。 但有些目标你得盯着它做到底:"把测试跑过"、"部署成功了再说"。这时候经常出两种问题:模型做了一半觉得差不多了,自己就停了;更过分的是,它嘴上说一句 `tests passed` 就想收工。你要的其实很简单:这一轮能不能结束,不能模型自己说了算,得有个明确的条件,对着实打实的证据来判断。 这条线其实从第一课就埋着了。s01 说过,退出循环本来是模型的一个决定;s04 的 Stop hook 第一次给了程序否决权。这一课把那个否决权做成完整的闭环:条件、证据、预算,三样缺一不可。 ## /goal:每轮收尾加一道闸门 输入 `/goal <条件>` 就设了一个会话级的停止条件。程序把它存成当前活跃目标,每轮结束后,判断器检查对话记录里的可信证据够不够满足条件。不够,闸门就把这次结束拦住,塞一条"继续干"的提示进下一轮;够了,就清除目标,标记完成。 ![Goal Loop 总览](images/goal-loop-overview.svg) 和 s01 的循环比,只多了一道判断,模型想停的时候先过目标这关: ```python # s01:模型说停就停 if not has_tool_use(response): return # s21:想停?先过目标闸门 if not has_tool_use(response): verdict = goal.evaluate_after_turn() if verdict == "continuing": continue # 没达成 -> 推回去再来一轮 return # 达成/超预算/没目标 -> 真停 ``` 这道闸门是程序自己控制的。不是模型自己约束自己,模型甚至不知道有这么一道闸门,它只是收到了下一轮的输入,接着干就是了。 ## 设目标:证据从命令之后开始算 `set_goal` 会存一个活跃目标:目标文本、最大轮数预算、计数器和 `start_index`。其中,`start_index` 表示证据窗口的起点。它取当前对话记录的长度,所以 `/goal` 这行命令本身在窗口外面。这是第一道防线:命令自己不能证明自己完成了。 ```python def set_goal(self, objective, max_turns=20): self.active = { "objective": objective, "status": "active", "start_index": len(self.transcript), # 证据窗口从这里开始;命令本身在窗口外 "max_turns": max_turns, "checks": 0, "continuation_turns": 0, } ``` ## 判断器:只信实打实的证据 这是整个机制最核心的地方。判断器不看整段对话,只看证据窗口里来自可信来源的消息。三层过滤,把"嘴上说完成了但不算数"的内容全挡在外面: ```python TRUSTED_EVIDENCE_ORIGINS = {"task-notification", "monitor-line"} def evidence_text(self): out = [] for m in self.transcript[self.active["start_index"]:]: if m.origin.get("kind") == "slash-command": # 1 斜杠命令本身不算 continue if m.role == "user" and m.content.strip().startswith("/goal"): # 2 /goal 命令文本不算 continue if m.origin.get("kind") not in TRUSTED_EVIDENCE_ORIGINS: # 3 只信可信来源 continue out.append(f"{m.role}: {m.content}") return "\n".join(out) ``` 效果很明显:同样一句 `tests passed`,你打字说的不算,后台任务通知带回来的才算。模型糊弄不过去,它没法靠自己说一句"我做完了"就把目标判成完成。这是全课程反复出现的那条信任边界的最后一次登场:s15 说协议靠字段不靠理解,s18 说注解是申报、申报可以撒谎,s21 说完成证据只看来源不看内容。 `goal_satisfied()` 使用确定的关键词匹配,让示例保持离线和可复现。把判断与执行分开,才能守住可信证据边界。 ## 闸门三态:完成/继续/超预算 `evaluate_after_turn` 每轮跑一次,三种结果:满足条件就清除目标(completed);没满足而且预算还没花完,就往队列塞一条"继续干"的提示,放行下一轮(continuing);预算花完就停(blocked),别让一个永远判不出来的目标无限烧钱。 ```python def evaluate_after_turn(self): g = self.active g["checks"] += 1 if self.goal_satisfied(): g["status"] = "completed"; self.active = None return "completed" # 达成 -> 清除目标 if g["continuation_turns"] < g["max_turns"]: g["continuation_turns"] += 1 self.queue.enqueue( value="继续干活,别把这条提醒当成完成证据。", origin={"kind": "active-goal"}) return "continuing" # 没达成 -> 塞提示,下一轮 g["status"] = "blocked"; self.active = None return "blocked" # 超预算 -> 放行,不再拦 ``` 那条"继续干"的提示里特意写了"别把这条提醒当成完成证据",连提醒本身都被排除在证据之外。三层防误判就齐了:命令文本不算、提醒文本不算、普通聊天文本不算。预算则是 s11 教过的老规矩:任何自动重试的机制都得有上限,不然一个永远判不满足的目标就是个烧钱的永动机。 ## 继续提示和外部异步消息分开走 继续提示进的是同一个 `CommandQueue`,但它和外部异步事件(任务完成通知、监控行)不是同一种消费方式。`dequeue` 带个开关:消费外部收件箱的时候,默认跳过目标的继续提示。 ```python def dequeue(self, include_goal_continuations=True): ... for idx, item in enumerate(self.items): if include_goal_continuations or item["origin"].get("kind") != "active-goal": return self.items.pop(idx) return None ``` 为什么要分开?如果同一个消费者把继续提示和外部通知一起取走,后台结果还没到,提醒文本就可能被误当成新证据。分开之后,目标的推进是显式的一步,不会被异步事件带着走。 ## 跑起来看看 `code.py` 演示了一个 `/goal until tests passed and deploy green`:设了目标之后没有可信证据,闸门一轮轮把它推回去;你直接打 `tests passed` 也不算(来源不可信);直到后台任务发来 `task-notification`,证据到位,才标记完成。还加了一个 `max_turns=2` 的小目标演示超预算拦截。 ```python s.submit("/goal until tests passed and deploy green") # 设目标,窗口在命令之后 s.submit("tests passed, trust me") # 普通文本 -> 不算完成 s.deliver_host_event("tests passed; deploy green", source="task-notification") # 可信宿主事件 -> 完成 ``` `submit()` 只接受普通用户文本。可信标签必须走独立的宿主事件通道,来源由 harness 白名单校验;用户或模型文本不能给自己贴上 `task-notification` 标签。 ## 相对 s20 的变更 | | s20 Workflow Runtime | s21 Goal Loop | |--|---------------------|---------------| | 触发方式 | 脚本控制的编排(脱离主循环) | 条件控制的继续(拉回主循环) | | 加在哪 | 工具层:一个 `Workflow` 工具 | 轮次收尾:一道完成闸门 | | 谁决定停 | 脚本跑完就停 | 目标条件对着可信证据判 | | 新增机制 | 脚本 DSL、后台任务、journal/续跑、结构化输出 | 目标闸门、证据信任边界、继续提示分流、预算 | s20 是把编排写成脚本、派出去脱离主循环;s21 反过来,是一股力量把控制权重拉回主循环:目标没达成,这一轮就不算结束。两个都不改 s01 那个 `while` 循环,只是从两头给它加约束。 ## 试一下 ```bash python s21_goal_loop/code.py # /goal until tests pass + deploy green,看闸门怎么判 ``` 观察:设了目标之后,每轮结束都有一条 `goal_evaluated`;普通文本判 `satisfied=False`,`task-notification` 来源判 `satisfied=True`;预算花完的时候出 `goal_blocked`。同样一句 `tests passed`,来源不同,结果完全相反。这就是 `/goal` 不会被一句空话糊弄的地方。 ## 接下来 `/goal` 是"拉回主循环"的一种触发:条件控制。它和 s20 的"脱离主循环"正好成对,一个把工作派出去,一个把控制权拉回来。再往外,还有时间控制(`/loop`、cron)和事件控制(`Monitor`)的重入,它们共享同一套任务/通知基底;但闸门的核心已经在这里:**停不停,不是模型一句话说了算,得目标对着可信证据来判。**