mirror of
https://github.com/shareAI-lab/analysis_claude_code.git
synced 2026-09-22 05:13:48 +08:00
feat: consolidate course into 21 lessons
This commit is contained in:
152
s21_goal_loop/README.ja.md
Normal file
152
s21_goal_loop/README.ja.md
Normal file
@@ -0,0 +1,152 @@
|
||||
# s21: Goal Loop — いつ止まるかはモデルではなく goal が決める
|
||||
|
||||
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
|
||||
|
||||
s01 → ... → s19 → s20 → `s21`
|
||||
|
||||
> *「turn が終了できるかは goal condition を満たすかで決まり、モデルが stop と言っただけでは終わらない」* — `/goal` は main loop の各 turn の終端に gate を追加します。独立した evaluator が trusted evidence の充足を確認し、不足ならモデルを次のラウンドへ押し戻します。
|
||||
>
|
||||
> **Harness 層**: Goal closure — turn 終端に program-controlled completion gate を追加します。
|
||||
|
||||
---
|
||||
|
||||
s01 から s20 まで、会話の 1 turn はどう終わったでしょうか。モデルが `tool_use` を出さなくなると、loop はそのまま `return` しました。one-shot task なら問題ありません。終わったら止まります。
|
||||
|
||||
しかし「テストを通す」「deploy が成功するまで続ける」のように、最後まで見届けるべき goal もあります。そこでは 2 つの問題がよく起きます。モデルが途中まで進めて十分だと思い、自分で止まる。さらに悪ければ、口頭で `tests passed` と言うだけで終了しようとします。必要なことは単純です。turn が終了できるかをモデル自身に決めさせず、明示的な condition を実際の evidence に照らして判断します。
|
||||
|
||||
この流れは最初の章からありました。s01 は loop の exit がモデルの判断だと説明し、s04 の Stop hook が初めて program に veto を与えました。この章は、その veto を condition、evidence、budget の 3 要素が欠けない完全な loop にします。
|
||||
|
||||
## /goal: 各 turn の終端に gate を追加する
|
||||
|
||||
`/goal <condition>` を入力すると session-scoped stopping condition を設定します。program は active goal として保存し、各 turn の後に evaluator が transcript 内の trusted evidence を condition と照合します。不足なら gate が停止を拒み、次ラウンドへ「作業を続ける」prompt を queue します。十分なら goal を消して complete とします。
|
||||
|
||||

|
||||
|
||||
s01 の loop と比べて、追加されるのは 1 つの判断だけです。モデルが止まりたいとき、先に goal gate を通ります。
|
||||
|
||||
```python
|
||||
# s01: モデルが stop と言えば停止
|
||||
if not has_tool_use(response):
|
||||
return
|
||||
# s21: 止まりたい?先に goal gate を通る
|
||||
if not has_tool_use(response):
|
||||
verdict = goal.evaluate_after_turn()
|
||||
if verdict == "continuing":
|
||||
continue # 未達成 -> 次のラウンドへ押し戻す
|
||||
return # 達成 / budget 超過 / goal なし -> 本当に停止
|
||||
```
|
||||
|
||||
この gate を制御するのは program です。モデルが自分を律しているのではありません。モデルは gate の存在すら知らず、次のラウンドの入力を受け取って作業を続けるだけです。
|
||||
|
||||
## Goal の設定: Evidence は command の後から数える
|
||||
|
||||
`set_goal` は active goal として、goal text、最大 turn budget、counter、そして evidence window の開始点 `start_index` を保存します。現在の transcript length を使うため、`/goal` command 自身は window の外です。これが最初の防御です。command が自分自身の完了を証明することはできません。
|
||||
|
||||
```python
|
||||
def set_goal(self, objective, max_turns=20):
|
||||
self.active = {
|
||||
"objective": objective, "status": "active",
|
||||
"start_index": len(self.transcript), # evidence はここから。command 自身は window 外
|
||||
"max_turns": max_turns, "checks": 0, "continuation_turns": 0,
|
||||
}
|
||||
```
|
||||
|
||||
## Evaluator: 実在する evidence だけを信頼する
|
||||
|
||||
ここが仕組み全体の core です。evaluator は会話全体を見ず、evidence window 内で trusted source から来た message だけを見ます。3 層の filter が、「完了したと言ったから完了」という内容をすべて外へ止めます。
|
||||
|
||||
```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 slash command 自身は evidence ではない
|
||||
continue
|
||||
if m.role == "user" and m.content.strip().startswith("/goal"): # 2 /goal command text は evidence ではない
|
||||
continue
|
||||
if m.origin.get("kind") not in TRUSTED_EVIDENCE_ORIGINS: # 3 trusted origin だけを信頼
|
||||
continue
|
||||
out.append(f"{m.role}: {m.content}")
|
||||
return "\n".join(out)
|
||||
```
|
||||
|
||||
効果は明確です。同じ `tests passed` でも、あなたが入力したものは数えず、background task notification が持ち帰ったものだけを数えます。モデルは「完了した」と自分で言うだけでは goal を complete にできません。これはコース全体に繰り返し現れた trust boundary の最後の登場です。s15 は protocol が理解ではなく field に依存すると言い、s18 は annotation が申告であり、申告は嘘をつけると言い、s21 は completion evidence を content ではなく origin で信頼します。
|
||||
|
||||
`goal_satisfied()` は決定的な keyword matching を使い、例を offline かつ再現可能に保ちます。評価と実行を分けることで、trusted evidence boundary を維持します。
|
||||
|
||||
## Gate の 3 状態: Completed / continuing / budget 超過
|
||||
|
||||
`evaluate_after_turn` は各 turn で 1 回動き、3 つの結果を返します。condition が満たされれば goal を completed として消します。満たされず budget が残れば「作業を続ける」prompt を queue し、continuing として次ラウンドを許可します。budget を使い切れば blocked で gate を解除し、永遠に判定できない goal が無限に費用を使わないようにします。
|
||||
|
||||
```python
|
||||
def evaluate_after_turn(self):
|
||||
g = self.active
|
||||
g["checks"] += 1
|
||||
if self.goal_satisfied():
|
||||
g["status"] = "completed"; self.active = None
|
||||
return "completed" # 達成 -> goal を消す
|
||||
if g["continuation_turns"] < g["max_turns"]:
|
||||
g["continuation_turns"] += 1
|
||||
self.queue.enqueue(
|
||||
value="作業を続けてください。この reminder を completion evidence として扱わないでください。",
|
||||
origin={"kind": "active-goal"})
|
||||
return "continuing" # 未達成 -> prompt を queue し、次ラウンドへ
|
||||
g["status"] = "blocked"; self.active = None
|
||||
return "blocked" # budget 超過 -> gate を解除
|
||||
```
|
||||
|
||||
continuation prompt には、わざわざ自身を evidence にしないよう書き、filter でも除外します。これで false positive を防ぐ 3 層がそろいます。command text、reminder text、ordinary conversation のいずれも数えません。budget は s11 の古い規則に従います。automatic retry mechanism には必ず上限が必要です。そうでなければ、永遠に satisfied にならない goal が費用を燃やし続けます。
|
||||
|
||||
## Continuation prompt と外部 asynchronous message を分ける
|
||||
|
||||
continuation prompt は同じ `CommandQueue` に入りますが、task completion notification や monitor line といった外部 asynchronous event とは別の方法で消費します。`dequeue` には switch があり、外部 inbox を消費するときは goal continuation を既定で skip します。
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
なぜ分けるのでしょう。同じ consumer が continuation prompt と外部 notification を一緒に取り出すと、background result が届く前に reminder text を新しい evidence と誤認する可能性があります。分離後は goal の進行が明示的な 1 step になり、asynchronous event に偶然運ばれません。
|
||||
|
||||
## 実際に動かす
|
||||
|
||||
`code.py` は `/goal until tests passed and deploy green` を実演します。goal 設定後に trusted evidence がなければ、gate がラウンドごとに押し戻します。直接 `tests passed` と入力しても origin が信頼されないため数えません。background task が `task-notification` を送って初めて evidence がそろい、complete になります。`max_turns=2` の小さな goal で budget 超過も示します。
|
||||
|
||||
```python
|
||||
s.submit("/goal until tests passed and deploy green") # goal を設定。evidence は command 後から
|
||||
s.submit("tests passed, trust me") # ordinary text -> completion evidence ではない
|
||||
s.deliver_host_event("tests passed; deploy green",
|
||||
source="task-notification") # trusted host event -> complete
|
||||
```
|
||||
|
||||
`submit()` は通常のユーザーテキストだけを受け取る。trusted label は独立した host event channel から入り、source は harness の allowlist で検証される。ユーザーやモデルのテキストが自分に `task-notification` label を付けることはできない。
|
||||
|
||||
## s20 からの変更点
|
||||
|
||||
| | s20 Workflow Runtime | s21 Goal Loop |
|
||||
|--|---------------------|---------------|
|
||||
| trigger | script-controlled orchestration(main loop の外) | condition-controlled continuation(main loop へ引き戻す) |
|
||||
| 接続位置 | tool layer: 1 つの `Workflow` ツール | turn 終端: completion gate |
|
||||
| stop を決めるもの | script が完了 | goal condition を trusted evidence と照合 |
|
||||
| 新しい仕組み | script DSL、background task、journal/resume、structured output | goal gate、evidence trust boundary、continuation 分流、budget |
|
||||
|
||||
s20 は script-defined orchestration を main loop の外へ送り出します。s21 は反対の力で control を引き戻します。goal が未達成なら turn は終わっていません。どちらも s01 の `while` loop を変えず、両側から制約を加えます。
|
||||
|
||||
## 試してみる
|
||||
|
||||
```bash
|
||||
python s21_goal_loop/code.py # /goal until tests pass + deploy green。gate の判定を見る
|
||||
```
|
||||
|
||||
goal 設定後、各 turn が `goal_evaluated` を出す様子を確認してください。ordinary text は `satisfied=False`、同じ内容でも `task-notification` origin は `satisfied=True`、budget を使い切ると `goal_blocked` です。同じ `tests passed` でも origin によって結果が正反対になります。空疎な主張で `/goal` を欺けない理由です。
|
||||
|
||||
## 次へ
|
||||
|
||||
`/goal` は control を main loop へ引き戻す trigger の 1 つ、condition control です。s20 の main loop 外 orchestration と対になり、一方は仕事を外へ送り、もう一方は control を内へ戻します。その外側には `/loop` と cron による time-controlled re-entry、`Monitor` による event-controlled re-entry もあり、同じ task/notification 基盤を共有します。しかし gate の core はすでにここにあります。**stop するかはモデルの一言では決まらず、goal が trusted evidence に照らして判断します。**
|
||||
|
||||
<!-- translation-sync: zh@v2, en@v2, ja@v2 -->
|
||||
152
s21_goal_loop/README.md
Normal file
152
s21_goal_loop/README.md
Normal file
@@ -0,0 +1,152 @@
|
||||
# s21: Goal Loop — The Goal Decides When to Stop, Not the Model
|
||||
|
||||
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
|
||||
|
||||
s01 → ... → s19 → s20 → `s21`
|
||||
|
||||
> *"A turn ends only when the goal condition is satisfied, not merely when the model says stop"* — `/goal` adds a gate at the end of every main-loop turn. An independent evaluator checks whether trusted evidence is sufficient; if not, it pushes the model into another round.
|
||||
>
|
||||
> **Harness layer**: Goal closure — a program-controlled completion gate at the end of each turn.
|
||||
|
||||
---
|
||||
|
||||
From s01 through s20, how does a conversation turn end? When the model stops emitting `tool_use`, the loop simply executes `return`. That is fine for one-shot work: finish and stop.
|
||||
|
||||
Some objectives, however, must be carried through to completion: "get the tests passing" or "do not stop until the deployment succeeds." Two problems appear often. The model does half the work, decides it is close enough, and stops. Worse, it says `tests passed` and tries to declare victory. The requirement is simple: the model cannot decide by itself whether the turn may end. An explicit condition must be evaluated against concrete evidence.
|
||||
|
||||
This thread was present from the first chapter. s01 explained that exiting the loop is a model decision. s04's Stop hook gave the program veto power for the first time. This chapter turns that veto into a complete loop with three indispensable parts: condition, evidence, and budget.
|
||||
|
||||
## /goal: Add a Gate at the End of Every Turn
|
||||
|
||||
Entering `/goal <condition>` sets a session-scoped stopping condition. The program stores it as the active goal. After each turn, an evaluator checks whether trusted evidence in the transcript satisfies the condition. If evidence is insufficient, the gate blocks the attempted stop and queues a "keep working" prompt for the next round. If it is sufficient, the goal is cleared and marked complete.
|
||||
|
||||

|
||||
|
||||
Compared with the s01 loop, there is only one additional decision: when the model wants to stop, it must first pass the goal gate.
|
||||
|
||||
```python
|
||||
# s01: stop when the model says stop
|
||||
if not has_tool_use(response):
|
||||
return
|
||||
# s21: want to stop? Pass the goal gate first
|
||||
if not has_tool_use(response):
|
||||
verdict = goal.evaluate_after_turn()
|
||||
if verdict == "continuing":
|
||||
continue # Not achieved -> push back for another round
|
||||
return # Achieved / over budget / no goal -> really stop
|
||||
```
|
||||
|
||||
The program controls this gate. It is not the model restraining itself. The model does not even know the gate exists; it simply receives another round of input and continues working.
|
||||
|
||||
## Setting a Goal: Evidence Starts after the Command
|
||||
|
||||
`set_goal` stores an active goal containing the objective text, a maximum-turn budget, counters, and `start_index`, the beginning of the evidence window. It uses the transcript's current length, placing the `/goal` command itself outside the window. This is the first defense: a command cannot prove its own completion.
|
||||
|
||||
```python
|
||||
def set_goal(self, objective, max_turns=20):
|
||||
self.active = {
|
||||
"objective": objective, "status": "active",
|
||||
"start_index": len(self.transcript), # Evidence starts here; the command is outside the window
|
||||
"max_turns": max_turns, "checks": 0, "continuation_turns": 0,
|
||||
}
|
||||
```
|
||||
|
||||
## The Evaluator: Trust Concrete Evidence Only
|
||||
|
||||
This is the core of the entire mechanism. The evaluator does not inspect the whole conversation. It sees only messages inside the evidence window that come from trusted sources. Three filters keep every form of "I said it was done, so it must be done" outside:
|
||||
|
||||
```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 Slash commands are not evidence
|
||||
continue
|
||||
if m.role == "user" and m.content.strip().startswith("/goal"): # 2 /goal command text is not evidence
|
||||
continue
|
||||
if m.origin.get("kind") not in TRUSTED_EVIDENCE_ORIGINS: # 3 Trust only approved origins
|
||||
continue
|
||||
out.append(f"{m.role}: {m.content}")
|
||||
return "\n".join(out)
|
||||
```
|
||||
|
||||
The effect is clear. The same sentence, `tests passed`, does not count when typed by you, but does count when delivered by a background task notification. The model cannot bluff its way out by saying "I finished." This is the final appearance of the trust boundary repeated throughout the course. s15 said protocols rely on fields, not interpretation. s18 said annotations are claims and claims may be false. s21 says completion evidence is trusted by origin, not by content alone.
|
||||
|
||||
`goal_satisfied()` uses deterministic keyword matching so the example stays offline and reproducible. Keeping evaluation separate from execution preserves the trusted evidence boundary.
|
||||
|
||||
## Three Gate States: Completed, Continuing, or Over Budget
|
||||
|
||||
`evaluate_after_turn` runs after every turn and returns one of three results. If the condition is satisfied, it clears the goal as completed. If the condition is not satisfied and budget remains, it queues a "keep working" prompt and permits another round as continuing. If the budget is exhausted, it stops blocking and marks the goal blocked, preventing an impossible goal from burning money forever.
|
||||
|
||||
```python
|
||||
def evaluate_after_turn(self):
|
||||
g = self.active
|
||||
g["checks"] += 1
|
||||
if self.goal_satisfied():
|
||||
g["status"] = "completed"; self.active = None
|
||||
return "completed" # Achieved -> clear the goal
|
||||
if g["continuation_turns"] < g["max_turns"]:
|
||||
g["continuation_turns"] += 1
|
||||
self.queue.enqueue(
|
||||
value="Keep working. Do not treat this reminder as completion evidence.",
|
||||
origin={"kind": "active-goal"})
|
||||
return "continuing" # Not achieved -> queue a prompt for the next round
|
||||
g["status"] = "blocked"; self.active = None
|
||||
return "blocked" # Over budget -> release the gate
|
||||
```
|
||||
|
||||
The continuation prompt explicitly says not to treat itself as evidence, and the evidence filter excludes it. That completes the three layers against false positives: the command does not count, the reminder does not count, and ordinary conversation does not count. The budget follows the old rule from s11: every automatic retry mechanism needs a limit. Otherwise, a goal that can never be satisfied becomes a perpetual money-burning machine.
|
||||
|
||||
## Keep Continuation Prompts Separate from External Asynchronous Messages
|
||||
|
||||
Continuation prompts enter the same `CommandQueue`, but they are not consumed in the same way as external asynchronous events such as task-completion notifications and monitor lines. `dequeue` has a switch, and consumption of the external inbox skips goal continuations by default.
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
Why separate them? If one consumer drains continuation prompts together with external notifications, a reminder can be mistaken for new evidence before the background result arrives. With the paths separated, goal progression is an explicit step and cannot be carried along accidentally by asynchronous events.
|
||||
|
||||
## See It Run
|
||||
|
||||
`code.py` demonstrates `/goal until tests passed and deploy green`. With no trusted evidence after goal creation, the gate pushes it back round after round. Typing `tests passed` directly still does not count because the origin is untrusted. Only after a background task sends a `task-notification` does the evidence satisfy the goal. A second small goal with `max_turns=2` demonstrates the over-budget path.
|
||||
|
||||
```python
|
||||
s.submit("/goal until tests passed and deploy green") # Set the goal; evidence begins after this command
|
||||
s.submit("tests passed, trust me") # Ordinary text -> not completion evidence
|
||||
s.deliver_host_event("tests passed; deploy green",
|
||||
source="task-notification") # Trusted host event -> complete
|
||||
```
|
||||
|
||||
`submit()` accepts only ordinary user text. Trusted labels enter through the separate host-event channel, whose source is allowlisted by the harness; user or model text cannot attach its own `task-notification` label.
|
||||
|
||||
## Changes from s20
|
||||
|
||||
| | s20 Workflow Runtime | s21 Goal Loop |
|
||||
|--|---------------------|---------------|
|
||||
| Trigger | Script-controlled orchestration outside the main loop | Condition-controlled continuation pulled back into the main loop |
|
||||
| Attachment point | Tool layer: one `Workflow` tool | End of turn: a completion gate |
|
||||
| Who decides when to stop | The script finishes | Goal condition evaluated against trusted evidence |
|
||||
| New mechanisms | Script DSL, background tasks, journal/resume, structured output | Goal gate, evidence trust boundary, separate continuation path, budget |
|
||||
|
||||
s20 sends script-defined orchestration away from the main loop. s21 applies an opposite force that pulls control back: if the goal is not achieved, the turn is not finished. Neither changes the `while` loop from s01; each constrains it from a different side.
|
||||
|
||||
## Try It
|
||||
|
||||
```bash
|
||||
python s21_goal_loop/code.py # /goal until tests pass + deploy green; watch the gate decide
|
||||
```
|
||||
|
||||
After setting a goal, watch every turn produce `goal_evaluated`. Ordinary text yields `satisfied=False`; the same content from a `task-notification` origin yields `satisfied=True`; exhausted budget produces `goal_blocked`. The same `tests passed` sentence has opposite results depending on its origin. That is why an empty claim cannot fool `/goal`.
|
||||
|
||||
## Next
|
||||
|
||||
`/goal` is one kind of trigger that pulls control back into the main loop: condition control. It pairs naturally with s20's orchestration outside the main loop, one dispatching work outward and the other pulling control inward. Beyond them are time-controlled re-entry through `/loop` and cron, and event-controlled re-entry through `Monitor`; all share the same task and notification foundation. But the essential gate is already here: **the model's words do not decide whether to stop. The goal must judge trusted evidence.**
|
||||
|
||||
<!-- translation-sync: zh@v2, en@v2, ja@v2 -->
|
||||
152
s21_goal_loop/README.zh.md
Normal file
152
s21_goal_loop/README.zh.md
Normal file
@@ -0,0 +1,152 @@
|
||||
# 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 <条件>` 就设了一个会话级的停止条件。程序把它存成当前活跃目标,每轮结束后,判断器检查对话记录里的可信证据够不够满足条件。不够,闸门就把这次结束拦住,塞一条"继续干"的提示进下一轮;够了,就清除目标,标记完成。
|
||||
|
||||

|
||||
|
||||
和 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`——也就是证据窗口的起点。它取当前对话记录的长度,所以 `/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`)的重入,它们共享同一套任务/通知基底;但闸门的核心已经在这里:**停不停,不是模型一句话说了算,得目标对着可信证据来判。**
|
||||
|
||||
<!-- translation-sync: zh@v2, en@v2, ja@v2 -->
|
||||
285
s21_goal_loop/code.py
Normal file
285
s21_goal_loop/code.py
Normal file
@@ -0,0 +1,285 @@
|
||||
"""
|
||||
s21_goal_loop — minimal /goal session loop
|
||||
|
||||
Idea:
|
||||
s01-s20 end a turn when the model emits no tool_use. `/goal` adds a
|
||||
host-owned turn-completion GATE: the user sets a stopping CONDITION, and after
|
||||
every turn a separate evaluator judges whether trusted transcript evidence
|
||||
satisfies it. Not satisfied -> the gate blocks the stop and feeds a
|
||||
continuation into the next turn. Satisfied -> the active goal is cleared.
|
||||
|
||||
So the core contrast with s01 is one extra check before "return":
|
||||
|
||||
# s01: the model says stop -> stop
|
||||
if not has_tool_use(response):
|
||||
return
|
||||
# s21: when it wants to stop, pass the goal gate first
|
||||
if not has_tool_use(response):
|
||||
verdict = goal.evaluate_after_turn()
|
||||
if verdict == "continuing":
|
||||
continue # not met -> push it back
|
||||
return # met / over budget / no goal -> really stop
|
||||
|
||||
Run:
|
||||
python code.py # /goal until tests pass + deploy green; watch the gate
|
||||
|
||||
Implementation choices:
|
||||
- The evaluator is a deterministic keyword check, not a small/fast model.
|
||||
- One mock task-notification produces the trusted evidence; the loop / monitor
|
||||
/ background-task plane (s13/s14) is out of scope — this chapter is just the
|
||||
goal gate.
|
||||
- The evidence trust boundary is the important part: only task-notification /
|
||||
monitor-line origins count as evidence, so the `/goal` command text, the
|
||||
continuation reminder, and plain assistant prose can NOT satisfy the goal.
|
||||
Ordinary `submit()` calls cannot set those labels; only the host-event
|
||||
ingress can deliver an allowlisted source.
|
||||
"""
|
||||
|
||||
import itertools
|
||||
import sys
|
||||
|
||||
# ---- ids + a one-line event stream so the gate is visible ----
|
||||
_ids = itertools.count(1)
|
||||
|
||||
|
||||
def make_id(prefix):
|
||||
return f"{prefix}-{next(_ids):03d}"
|
||||
|
||||
|
||||
def event(lane, etype, detail=""):
|
||||
print(f" · {lane:<6} {etype:<26} {detail}")
|
||||
|
||||
|
||||
# A message's origin.kind is the TRUST LABEL that decides whether it can count
|
||||
# as goal evidence. Trusted async origins carry host-validated evidence; user /
|
||||
# slash-command / active-goal (the continuation reminder) / assistant do not.
|
||||
TRUSTED_EVIDENCE_ORIGINS = {"task-notification", "monitor-line"}
|
||||
|
||||
|
||||
class Message:
|
||||
def __init__(self, role, content, origin):
|
||||
self.role = role
|
||||
self.content = content
|
||||
self.origin = origin or {"kind": "user"}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# CommandQueue — continuation prompts live here
|
||||
# ============================================================
|
||||
class CommandQueue:
|
||||
PRIORITY = {"now": 0, "next": 1, "later": 2}
|
||||
|
||||
def __init__(self):
|
||||
self.items = []
|
||||
|
||||
def enqueue(self, value, priority="next", origin=None):
|
||||
item = {"id": make_id("cmd"), "priority": priority,
|
||||
"origin": origin or {}, "value": value}
|
||||
self.items.append(item)
|
||||
return item
|
||||
|
||||
def dequeue(self, include_goal_continuations=True):
|
||||
# Goal continuations and the external async inbox are NOT the same drain.
|
||||
# With include_goal_continuations=False an inbox drain skips them, so a
|
||||
# goal can't be advanced (or blocked) before real evidence arrives.
|
||||
self.items.sort(key=lambda i: self.PRIORITY.get(i["priority"], 1))
|
||||
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
|
||||
|
||||
def remove_by_origin(self, kind):
|
||||
before = len(self.items)
|
||||
self.items = [i for i in self.items if i["origin"].get("kind") != kind]
|
||||
return before - len(self.items)
|
||||
|
||||
def __len__(self):
|
||||
return len(self.items)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# GoalRuntime — the turn-completion gate
|
||||
# ============================================================
|
||||
class GoalRuntime:
|
||||
def __init__(self, transcript, queue):
|
||||
self.transcript = transcript # shared session transcript
|
||||
self.queue = queue
|
||||
self.active = None
|
||||
|
||||
def set_goal(self, objective, max_turns=20):
|
||||
# start_index marks the evidence window. The /goal command line is
|
||||
# already recorded, so it sits OUTSIDE the window and can't satisfy
|
||||
# itself.
|
||||
self.active = {
|
||||
"id": make_id("goal"), "objective": objective, "status": "active",
|
||||
"start_index": len(self.transcript), "max_turns": max_turns,
|
||||
"checks": 0, "continuation_turns": 0,
|
||||
}
|
||||
event("goal", "goal_started", f"{self.active['id']} :: {objective}")
|
||||
return self.active
|
||||
|
||||
def clear(self, reason="cleared"):
|
||||
if not self.active:
|
||||
return
|
||||
self.active["status"] = reason
|
||||
self.queue.remove_by_origin("active-goal")
|
||||
event("goal", "goal_cleared", reason)
|
||||
self.active = None
|
||||
|
||||
def evidence_text(self):
|
||||
"""The trust boundary. Three filters keep self-satisfying text out:
|
||||
drop slash-command origins, drop /goal command lines, and keep ONLY
|
||||
trusted external async origins (task-notification / monitor-line)."""
|
||||
if not self.active:
|
||||
return ""
|
||||
out = []
|
||||
for m in self.transcript[self.active["start_index"]:]:
|
||||
if m.origin.get("kind") == "slash-command":
|
||||
continue
|
||||
if m.role == "user" and m.content.strip().startswith("/goal"):
|
||||
continue
|
||||
if m.origin.get("kind") not in TRUSTED_EVIDENCE_ORIGINS:
|
||||
continue
|
||||
out.append(f"{m.role}: {m.content}")
|
||||
return "\n".join(out)
|
||||
|
||||
def goal_satisfied(self):
|
||||
# Evaluate only the trusted evidence window with a deterministic policy.
|
||||
objective = self.active["objective"].lower()
|
||||
evidence = self.evidence_text().lower()
|
||||
wants_tests = "test" in objective
|
||||
wants_deploy = "deploy" in objective or "green" in objective
|
||||
tests_ok = not wants_tests or "tests passed" in evidence or "test passed" in evidence
|
||||
deploy_ok = not wants_deploy or "deploy green" in evidence or "deployment green" in evidence
|
||||
if any(k in objective for k in ("until", "pass", "green")):
|
||||
return tests_ok and deploy_ok
|
||||
return objective in evidence
|
||||
|
||||
def evaluate_after_turn(self):
|
||||
"""The gate, run after every turn. Returns completed / continuing /
|
||||
blocked / none."""
|
||||
g = self.active
|
||||
if not g or g["status"] != "active":
|
||||
return "none"
|
||||
g["checks"] += 1
|
||||
satisfied = self.goal_satisfied()
|
||||
event("goal", "goal_evaluated", f"check #{g['checks']} satisfied={satisfied}")
|
||||
if satisfied:
|
||||
g["status"] = "completed"
|
||||
self.queue.remove_by_origin("active-goal")
|
||||
event("goal", "goal_completed", g["id"])
|
||||
self.active = None
|
||||
return "completed"
|
||||
if g["continuation_turns"] < g["max_turns"]:
|
||||
g["continuation_turns"] += 1
|
||||
self.queue.enqueue(
|
||||
value=(f"Continue working toward active goal {g['id']}. Use tool/task "
|
||||
"evidence; do not treat this reminder as completion evidence."),
|
||||
priority="next", origin={"kind": "active-goal", "goal_id": g["id"]})
|
||||
event("goal", "goal_continuation_enqueued",
|
||||
f"turn {g['continuation_turns']}/{g['max_turns']}")
|
||||
return "continuing"
|
||||
g["status"] = "blocked"
|
||||
self.queue.remove_by_origin("active-goal")
|
||||
event("goal", "goal_blocked", f"exceeded {g['max_turns']} turns")
|
||||
self.active = None
|
||||
return "blocked"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Session — the main loop host with a Stop gate
|
||||
# ============================================================
|
||||
class Session:
|
||||
def __init__(self):
|
||||
self.transcript = []
|
||||
self.queue = CommandQueue()
|
||||
self.goal = GoalRuntime(self.transcript, self.queue)
|
||||
|
||||
def _add(self, role, content, origin):
|
||||
self.transcript.append(Message(role, content, origin))
|
||||
|
||||
def submit(self, text):
|
||||
"""Submit ordinary user text. Callers cannot attach a trusted origin."""
|
||||
return self._submit(text, {"kind": "user"})
|
||||
|
||||
def deliver_host_event(self, text, source):
|
||||
"""Host-only ingress for validated task/monitor events."""
|
||||
if source not in TRUSTED_EVIDENCE_ORIGINS:
|
||||
raise ValueError(f"untrusted host event source: {source}")
|
||||
return self._submit(text, {"kind": source})
|
||||
|
||||
def _submit(self, text, origin):
|
||||
"""Run one turn with an origin already assigned by the host."""
|
||||
self._add("user", text, origin) # input recorded with its origin
|
||||
kind = origin["kind"]
|
||||
|
||||
if kind == "user" and text.strip().startswith("/goal"):
|
||||
arg = text.strip()[5:].strip()
|
||||
self._add("assistant", f"(slash) /goal {arg}", {"kind": "slash-command"})
|
||||
if arg in ("", "clear", "stop", "off"):
|
||||
self.goal.clear()
|
||||
else:
|
||||
self.goal.set_goal(arg)
|
||||
elif kind in TRUSTED_EVIDENCE_ORIGINS:
|
||||
# The input itself (recorded above with a trusted origin) is the
|
||||
# evidence; the assistant just observes it.
|
||||
event("turn", f"observe {kind}", text[:48])
|
||||
self._add("assistant", f"Observed {kind}: {text}", origin)
|
||||
elif kind == "active-goal":
|
||||
event("turn", "continue-goal", "(reminder is not evidence)")
|
||||
self._add("assistant", "Continuing the goal; checking task/monitor evidence.", origin)
|
||||
else:
|
||||
event("turn", "assistant-turn", text[:48])
|
||||
self._add("assistant", f"assistant handled: {text}", {"kind": "assistant"})
|
||||
|
||||
return self.goal.evaluate_after_turn() # <-- the Stop gate
|
||||
|
||||
def drain_goal_continuation(self):
|
||||
"""Pull one goal continuation back into the loop — explicit, separate
|
||||
from any external async-inbox drain."""
|
||||
item = self.queue.dequeue(include_goal_continuations=True)
|
||||
if item and item["origin"].get("kind") == "active-goal":
|
||||
return self._submit(item["value"], item["origin"])
|
||||
return None
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Demo
|
||||
# ============================================================
|
||||
def banner(text):
|
||||
print(f"\n— {text} —")
|
||||
|
||||
|
||||
def main(argv):
|
||||
s = Session()
|
||||
|
||||
banner("1. set a goal (the gate is now armed; window starts after the command)")
|
||||
print("user> /goal until tests passed and deploy green")
|
||||
s.submit("/goal until tests passed and deploy green")
|
||||
|
||||
banner("2. model works, no TRUSTED evidence yet -> the gate keeps it going")
|
||||
s.drain_goal_continuation()
|
||||
s.submit("Inspecting the failing tests and the deploy config.")
|
||||
|
||||
banner("3. plain user text 'tests passed' is NOT trusted -> still not satisfied")
|
||||
s.submit("tests passed, trust me")
|
||||
s.drain_goal_continuation()
|
||||
print(f" active goal still open: {s.goal.active is not None}")
|
||||
|
||||
banner("4. a background task lands a task-notification (trusted) -> satisfied")
|
||||
verdict = s.deliver_host_event(
|
||||
"tests passed; deploy green", source="task-notification"
|
||||
)
|
||||
print(f" final verdict: goal {verdict}")
|
||||
|
||||
banner("5. budget: a goal that never gets evidence blocks after max_turns")
|
||||
s2 = Session()
|
||||
s2.goal.set_goal("until tests passed", max_turns=2)
|
||||
verdict = "continuing"
|
||||
while verdict == "continuing":
|
||||
verdict = s2.submit("still working, no task evidence yet")
|
||||
print(f" final verdict: goal {verdict}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main(sys.argv[1:])
|
||||
109
s21_goal_loop/images/goal-loop-overview.svg
Normal file
109
s21_goal_loop/images/goal-loop-overview.svg
Normal file
@@ -0,0 +1,109 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 540" font-family="system-ui, -apple-system, sans-serif">
|
||||
<defs>
|
||||
<marker id="arrow-green" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" fill="#22c55e"/>
|
||||
</marker>
|
||||
<marker id="arrow-gray" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" fill="#888888"/>
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
<!-- Background -->
|
||||
<rect width="960" height="540" rx="8" fill="#ffffff"/>
|
||||
|
||||
<!-- Title -->
|
||||
<text x="480" y="30" text-anchor="middle" fill="#1a1a1a" font-size="19" font-weight="700">Goal Loop — the host-owned turn-completion gate</text>
|
||||
<text x="480" y="50" text-anchor="middle" fill="#888888" font-size="12">after every turn an evaluator judges trusted evidence and blocks the stop until the goal is met</text>
|
||||
|
||||
<!-- ===== Main loop container ===== -->
|
||||
<rect x="20" y="66" width="920" height="156" rx="8" fill="#ffffff" stroke="#d0d0d0" stroke-width="1.5" stroke-dasharray="6,3"/>
|
||||
<text x="40" y="86" fill="#1a1a1a" font-size="13" font-weight="700">Main loop — the turn boundary</text>
|
||||
|
||||
<!-- loop-back over the top: continuation -> messages[] -->
|
||||
<path d="M 910 350 L 910 104 L 101 104 L 101 130" fill="none" stroke="#22c55e" stroke-width="1.5" stroke-dasharray="6,3" marker-end="url(#arrow-green)"/>
|
||||
<text x="500" y="99" text-anchor="middle" fill="#22c55e" font-size="10" font-weight="600">continuation -> transcript[] (next turn)</text>
|
||||
<circle cx="101" cy="130" r="3" fill="#22c55e"/>
|
||||
|
||||
<!-- transcript[] -->
|
||||
<rect x="40" y="130" width="122" height="52" rx="6" fill="#ffffff" stroke="#1a1a1a" stroke-width="1.5" stroke-dasharray="6,3"/>
|
||||
<text x="101" y="152" text-anchor="middle" fill="#1a1a1a" font-size="12" font-weight="700" font-family="monospace">transcript[]</text>
|
||||
<text x="101" y="169" text-anchor="middle" fill="#888888" font-size="9">messages + origins</text>
|
||||
<line x1="162" y1="156" x2="180" y2="156" stroke="#22c55e" stroke-width="2" marker-end="url(#arrow-green)"/>
|
||||
|
||||
<!-- turn (LLM) -->
|
||||
<rect x="182" y="130" width="108" height="52" rx="6" fill="#ffffff" stroke="#1a1a1a" stroke-width="1.5"/>
|
||||
<text x="236" y="152" text-anchor="middle" fill="#1a1a1a" font-size="12" font-weight="700">turn (LLM)</text>
|
||||
<text x="236" y="169" text-anchor="middle" fill="#888888" font-size="9">may emit tool_use</text>
|
||||
<line x1="290" y1="156" x2="308" y2="156" stroke="#22c55e" stroke-width="2" marker-end="url(#arrow-green)"/>
|
||||
|
||||
<!-- no tool_use -->
|
||||
<rect x="310" y="130" width="118" height="52" rx="6" fill="#ffffff" stroke="#1a1a1a" stroke-width="1.5"/>
|
||||
<text x="369" y="152" text-anchor="middle" fill="#1a1a1a" font-size="12" font-weight="700">no tool_use</text>
|
||||
<text x="369" y="169" text-anchor="middle" fill="#888888" font-size="9">(wants to stop)</text>
|
||||
<line x1="428" y1="156" x2="446" y2="156" stroke="#22c55e" stroke-width="2" marker-end="url(#arrow-green)"/>
|
||||
|
||||
<!-- goal gate (emphasis: thicker border) -->
|
||||
<rect x="448" y="124" width="170" height="64" rx="6" fill="#ffffff" stroke="#1a1a1a" stroke-width="2.4"/>
|
||||
<text x="533" y="146" text-anchor="middle" fill="#1a1a1a" font-size="12" font-weight="700">goal gate</text>
|
||||
<text x="533" y="163" text-anchor="middle" fill="#888888" font-size="9.5" font-family="monospace">evaluate_after_turn()</text>
|
||||
<text x="533" y="177" text-anchor="middle" fill="#888888" font-size="8.5">after every turn</text>
|
||||
|
||||
<!-- gate -> return (stop): gray -->
|
||||
<line x1="618" y1="156" x2="664" y2="156" stroke="#888888" stroke-width="1.5" marker-end="url(#arrow-gray)"/>
|
||||
<text x="641" y="148" text-anchor="middle" fill="#888888" font-size="8.5">completed</text>
|
||||
<text x="641" y="178" text-anchor="middle" fill="#888888" font-size="8.5">/ blocked</text>
|
||||
|
||||
<!-- return -->
|
||||
<rect x="666" y="130" width="120" height="52" rx="6" fill="#fafafa" stroke="#d0d0d0" stroke-width="1.5"/>
|
||||
<text x="726" y="152" text-anchor="middle" fill="#1a1a1a" font-size="12" font-weight="700">return</text>
|
||||
<text x="726" y="169" text-anchor="middle" fill="#888888" font-size="9">turn ends</text>
|
||||
|
||||
<!-- ===== gate consults: evaluator -> evidence ===== -->
|
||||
<!-- gate -> evaluator (judge) -->
|
||||
<line x1="500" y1="188" x2="500" y2="248" stroke="#22c55e" stroke-width="2" marker-end="url(#arrow-green)"/>
|
||||
<text x="510" y="222" fill="#22c55e" font-size="9" font-weight="600">judge</text>
|
||||
|
||||
<rect x="416" y="250" width="168" height="50" rx="6" fill="#ffffff" stroke="#1a1a1a" stroke-width="1.5"/>
|
||||
<text x="500" y="272" text-anchor="middle" fill="#1a1a1a" font-size="12" font-weight="700">evaluator</text>
|
||||
<text x="500" y="289" text-anchor="middle" fill="#888888" font-size="9">separate small / fast model</text>
|
||||
|
||||
<!-- evaluator -> evidence (reads) -->
|
||||
<line x1="500" y1="300" x2="500" y2="338" stroke="#22c55e" stroke-width="2" marker-end="url(#arrow-green)"/>
|
||||
<text x="510" y="324" fill="#22c55e" font-size="9" font-weight="600">reads</text>
|
||||
|
||||
<!-- evidence trust boundary container -->
|
||||
<rect x="40" y="340" width="606" height="156" rx="8" fill="#ffffff" stroke="#d0d0d0" stroke-width="1.5" stroke-dasharray="6,3"/>
|
||||
<text x="60" y="361" fill="#1a1a1a" font-size="12" font-weight="700">evidence window</text>
|
||||
<text x="188" y="361" fill="#888888" font-size="10" font-family="monospace">= transcript[start_index:] · trust boundary</text>
|
||||
|
||||
<!-- trusted -->
|
||||
<rect x="62" y="376" width="270" height="104" rx="6" fill="#ffffff" stroke="#1a1a1a" stroke-width="1.5"/>
|
||||
<text x="197" y="399" text-anchor="middle" fill="#1a1a1a" font-size="11" font-weight="700">counts as evidence</text>
|
||||
<text x="197" y="424" text-anchor="middle" fill="#1a1a1a" font-size="11" font-family="monospace">task-notification</text>
|
||||
<text x="197" y="446" text-anchor="middle" fill="#1a1a1a" font-size="11" font-family="monospace">monitor-line</text>
|
||||
<text x="197" y="468" text-anchor="middle" fill="#888888" font-size="8.5">trusted async origins</text>
|
||||
|
||||
<!-- untrusted -->
|
||||
<rect x="354" y="376" width="270" height="104" rx="6" fill="#fafafa" stroke="#d0d0d0" stroke-width="1.5"/>
|
||||
<text x="489" y="399" text-anchor="middle" fill="#888888" font-size="11" font-weight="700">filtered out</text>
|
||||
<text x="489" y="421" text-anchor="middle" fill="#888888" font-size="10">/goal command text</text>
|
||||
<text x="489" y="439" text-anchor="middle" fill="#888888" font-size="10">continuation reminder</text>
|
||||
<text x="489" y="457" text-anchor="middle" fill="#888888" font-size="10">plain user / assistant</text>
|
||||
<text x="489" y="475" text-anchor="middle" fill="#888888" font-size="8.5">model can't self-satisfy</text>
|
||||
|
||||
<!-- ===== continuing -> CommandQueue -> loop back ===== -->
|
||||
<!-- gate -> CommandQueue (continuing) -->
|
||||
<path d="M 600 188 L 600 230 L 786 230 L 786 322" fill="none" stroke="#22c55e" stroke-width="2" marker-end="url(#arrow-green)"/>
|
||||
<text x="700" y="223" text-anchor="middle" fill="#22c55e" font-size="9" font-weight="600">continuing</text>
|
||||
|
||||
<!-- CommandQueue (mutable: dashed black) -->
|
||||
<rect x="696" y="324" width="180" height="56" rx="6" fill="#ffffff" stroke="#1a1a1a" stroke-width="1.5" stroke-dasharray="6,3"/>
|
||||
<text x="786" y="347" text-anchor="middle" fill="#1a1a1a" font-size="11" font-weight="700">CommandQueue</text>
|
||||
<text x="786" y="364" text-anchor="middle" fill="#888888" font-size="9" font-family="monospace">continuation (active-goal)</text>
|
||||
|
||||
<!-- CommandQueue -> loop back (up the right edge, joins the over-top path) -->
|
||||
<line x1="876" y1="350" x2="908" y2="350" stroke="#22c55e" stroke-width="1.5" stroke-dasharray="6,3"/>
|
||||
|
||||
<!-- ===== Bottom note ===== -->
|
||||
<text x="480" y="520" text-anchor="middle" fill="#888888" font-size="10">The model proposes stop; the goal gate decides against trusted evidence only, not the model's own say-so.</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.7 KiB |
Reference in New Issue
Block a user