docs: make workflow and goal lessons harness-first

This commit is contained in:
Haoran
2026-07-30 20:05:33 +08:00
parent cb8fae1bdd
commit 4bc33ec858
12 changed files with 115 additions and 207 deletions

View File

@@ -8,9 +8,7 @@ s01 → ... → s19 → s20 → `s21` → [s22](../s22_goal_loop/)
>
> **Harness 層**: Orchestration — single-agent loop の上に、決定的な multi-agent script runtime を追加します。
> **情報源の境界:** この章の製品詳細は Claude Code 2.1.177 の clean-room 行動再構成に基づく。後続リリースで名称や制限は変わり得る。`code.py` はオフライン教材モデルであり、製品ソースの複製ではない
>
> 教材 CLI は `async_launched` を出した後、再現可能な出力のため同じプロセスで完了を待つ。示すのは lifecycle と journal であり、main loop の並行実行そのものではない。
`code.py` は demo を決定的に保つため、`async_launched` を出した後、同じ process で完了を待ちます。常駐 background service を用意しなくても、lifecycle と journal を確認できます
---
@@ -26,9 +24,9 @@ s01 から s20 まで、loop は常にモデル駆動で 1 step ずつ進みま
## 計画は chat のラウンドを重ねず、コードに書く
Claude Code の tool pool に `Workflow` ツールがあります。あなたが渡すか、モデルが high-intensity mode で起動した script は、`agent() / parallel() / pipeline() / phase()` という少数の primitive を使い、orchestration を決定的なコードとして表します。
harness の tool pool に `Workflow` ツールを追加します。ユーザーまたはモデルが渡す script は、`agent() / parallel() / pipeline() / phase()` という少数の primitive を使い、orchestration を決定的なコードとして表します。
main loop から見えるのは 1 回の `tool_use` だけで、すぐ「バックグラウンドで起動済み」という結果を受け取ります。本当の実行は background runtime で進み、進捗をリアルタイムに報告し、全過程をディスク上の journal へ記録します。script の中間結果は変数に保存され、会話履歴の場所を取りません。`resumeFromRunId` で再開すると、変更されていない `agent()` は journal cache に当たり、以前の結果を直接使って checkpoint から続行します。
main loop から見えるのは 1 回の `tool_use` だけで、すぐ「バックグラウンドで起動済み」という結果を受け取ります。本当の実行は background runtime で進み、進捗をリアルタイムに報告し、全過程をディスク上の journal へ記録します。script の中間結果は変数に保存され、会話履歴の場所を取りません。`resume_from_run_id` で再開すると、変更されていない `agent()` は journal cache に当たり、以前の結果を直接使って checkpoint から続行します。
![Workflow Runtime Overview](images/workflow-runtime-overview.svg)
@@ -45,7 +43,7 @@ async def sample_workflow(ctx, args):
## Workflow ツール: バックグラウンド起動、main loop には 1 回の call だけ
`Workflow`(別名 `RunWorkflow`は main Agent の tool pool にあります。明示的に「この workflow 実行」と頼む、保存済みの `/command` を使う、またはモデルが自動で high-intensity path へ入ると、モデルが `Workflow(...)` tool call を出します。
`Workflow` は main Agent の tool pool にあります。ユーザーが保存済み workflow 実行を求めるか、タスクが既知の orchestration に一致したときにモデルがこのツールを選びます。どちらも 1 回の `Workflow(...)` tool call になります。
ツールは argument を parse し、meta 情報を検証し、permission check を通し、local workflow task を登録すると、すぐ「非同期で起動済み」と返します。main loop は block せず別の仕事を続け、workflow は background で実行されます。これは s13 の引換券 pattern を拡大したものです。先に引換券を渡し、結果ができたら通知します。
@@ -60,11 +58,9 @@ class WorkflowTool:
... # 残りはバックグラウンドで進む
```
> 実際の Claude Code は `{status:'async_launched', taskId, taskType:'local_workflow', runId, summary, transcriptDir, scriptPath}` をすぐ返し、background task の完了後に通知します。
## Workflow metadata: 起動前に検証する
## Script と meta: 1 行目を正しく書く
script の 1 行目は必ず `export const meta = { name, description, phases }` とし、変数、関数呼び出し、文字列連結を含まない純粋な literal でなければなりません。runtime はコードを一切実行する前に parse します。`name``description` は task と UI の表示に使い、`phases` は progress bar の group 名を定義します。
各 workflow は `name``description`、任意の `phases` を持つ metadata object を登録します。runtime は workflow code を実行する前に検証します。`name``description` は task と UI の表示に使い、`phases` は progress bar の group 名を定義します。
不正な入力はすぐ `WorkflowInputError` になり、登録時に止まります。s14 の cron 式検証と同じ考えです。不正な script が実行時まで進んでから壊れないようにします。
@@ -86,8 +82,6 @@ def validate_meta(meta):
return meta
```
> 実際の Claude Code の `parseWorkflowScript` は、meta を 1 行目の純粋な literal に限定します。教材版は dict を直接受け取り、この部分を簡略化しています。
## Orchestration primitive: この少数だけで、すべての flow を書ける
script は独立した context で動き、global variable として使えるのは少数の orchestration primitive だけです。script 自身はファイルを直接読み書きせず、shell も実行しません。実際のコード操作は、派遣された subagent が自分の tool permission で行います。primitive はすべて `ExecutionState` の method です。
@@ -113,8 +107,6 @@ async def pipeline(self, items, *stages):
return await asyncio.gather(*[run_item(it, i) for i, it in enumerate(items)])
```
> 実際の Claude Code は同名 primitive を script VM の context へ注入します。さらに `args`、total/spent/remaining を持つ `budget`、最大 1000 Agent の上限、concurrency semaphore も提供します。
## 構造化出力: Subagent に散文を返させない
`agent({schema})` は、schema に一致する JSON object を subagent に要求します。内部では structured output call を 1 回使い、runtime が結果を schema で検証し、不一致なら 1 回 retry します。下流コードが受け取るのは規則的な object であり、再 parse が必要な長文ではありません。
@@ -132,8 +124,6 @@ if schema is not None:
raise WorkflowInputError(f"agent({{schema}}) の出力が不正です: {err}")
```
> 実際の Claude Code は `SimpleJsonSchema`、`StructuredOutput` ツール、schema-aware retry を組み合わせ、出力形式を保証します。
## Background task と progress event
`LocalWorkflowTask` は status と token usage を管理し、SDK style の event stream を外へ出します。`task_started` → phase change、subagent start、log batch を含む一連の `task_progress` → 完了、失敗、停止に加え、output file、token 数、tool call 数、所要時間を含む最後の `task_notification` です。
@@ -147,11 +137,9 @@ class LocalWorkflowTask:
print(f" progress {ptype} ...")
```
> 実際の Claude Code は進捗を task state へまとめ、`task_progress.workflow_progress` として UI と SDK へ送ります。
## 保存: Snapshot + journal で中断から再開する
各 run は `~/.claude/projects/<project>/<session>/` に 5 種類を書きます。`<runId>.json` snapshot、`<runId>.output.json` output、`<runId>.journal.jsonl` journal`scripts/<runId>.js` の script copy、`subagents/workflows/<runId>/` の subagent transcript です。保存した再利用可能な workflow は project scope の `.claude/workflows/` または user scope の `~/.claude/workflows/` に置きます。
この最小 runtime は各 run を `s21_workflow_runtime/.runtime/` に保存します。`<runId>.json` snapshot、`<runId>.output.json` output、`<runId>.journal.jsonl` journal です。production harness では workflow script や subagent transcript も保存できますが、snapshot と journal が安定した `runId` を共有することが重要です。
journal は checkpoint resume の中心で、各 `agent()` の結果を 1 行ずつ記録します。
@@ -165,7 +153,7 @@ class WorkflowJournal:
## Resume: runId から続行し、変更のないものを再利用する
`Workflow({scriptPath, resumeFromRunId, args})`呼ぶと script を再実行しますが、各 `agent()` は決定的な semantic key を計算します。journal に key があれば、再実行せず cached result を返します。変更のない call はすべて cache hit し、変更された call とそれに依存する後続 step だけが本当に動きます。
`resume_from_run_id` を渡して workflow を再度呼ぶと script を再実行しますが、各 `agent()` は決定的な semantic key を計算します。journal に key があれば、再実行せず cached result を返します。変更された call とそれに依存する後続 step だけが本当に動きます。
key は concurrency の完了順に依存してはいけません。`parallel``pipeline` の Agent は不定の順番で完了します。「何番目に完了したか」を key にすると、次回の cache が別の call へ対応してしまいます。そのため key は競合する counter ではなく、call の内容、つまり type、label、prompt、schema の stable hash です。
@@ -181,11 +169,9 @@ if cached is not MISS:
return cached
```
> 実際の Claude Code も「決定的 semantic key + journal cache」という考えです。同じ session で resume すると、完了済み `agent()` は cached result を直接返し、その後だけを実行します。
## 決定性: Resume に意味を持たせる再現性
resume が動くには、まず script が再現可能でなければなりません。runtime は `Date.now()`、引数なしの `new Date()``Math.random()` などの非決定的なものを script context から取り除き、Node native API も渡しません。同じ script + 同じ argument 同じ key → 100% cache hit になります。教材版は stable hash で同じ性質を得ます。実際の版は、非決定的な source を除いた sandbox VM で JavaScript 全体を実行します。
resume が動くには、workflow が再現可能でなければなりません。この最小 Python runtime は stable hash と決定的な mock runner を使い、同じ workflow + 同じ argument から同じ key を作ります。production harness では workflow code も隔離し、制御されていない clock、randomness、filesystem access などの非決定的な source を除くべきです。
## 実際に動かす

View File

@@ -8,9 +8,7 @@ s01 → ... → s19 → s20 → `s21` → [s22](../s22_goal_loop/)
>
> **Harness layer**: Orchestration — a deterministic multi-agent script runtime above the single-agent loop.
> **Source boundary:** Product details in this chapter are a clean-room behavioral reconstruction of Claude Code 2.1.177. Names and limits may change in later releases; `code.py` is an offline teaching model, not copied product source.
>
> The teaching CLI emits `async_launched` and then awaits completion in one process for deterministic output. It demonstrates the lifecycle and journal, not a concurrently running main loop.
`code.py` keeps the demo deterministic: it emits `async_launched` and then awaits completion in one process. This demonstrates the lifecycle and journal without requiring a long-running background service.
---
@@ -26,9 +24,9 @@ Making the model drive this process one round at a time in the main loop is slow
## Put the Plan in Code, Not in a Sequence of Chat Turns
Claude Code includes a `Workflow` tool in its tool pool. You, or the model when it enters a high-intensity mode, provide a script that expresses deterministic orchestration through a few simple primitives: `agent()`, `parallel()`, `pipeline()`, and `phase()`.
Add a `Workflow` tool to the harness tool pool. The user or model provides a script that expresses deterministic orchestration through a few simple primitives: `agent()`, `parallel()`, `pipeline()`, and `phase()`.
The main loop sees only one `tool_use` and immediately receives a "started in the background" result. Real execution continues inside the background runtime, which reports progress in real time and records every step in a journal on disk. Intermediate script results live in variables instead of taking space in conversation history. When restarted with `resumeFromRunId`, unchanged `agent()` calls hit the journal cache and reuse previous results, resuming from the checkpoint.
The main loop sees only one `tool_use` and immediately receives a "started in the background" result. Real execution continues inside the background runtime, which reports progress in real time and records every step in a journal on disk. Intermediate script results live in variables instead of taking space in conversation history. When restarted with `resume_from_run_id`, unchanged `agent()` calls hit the journal cache and reuse previous results, resuming from the checkpoint.
![Workflow Runtime Overview](images/workflow-runtime-overview.svg)
@@ -45,7 +43,7 @@ async def sample_workflow(ctx, args):
## The Workflow Tool: Start in the Background; the Main Loop Sees One Call
`Workflow`, also known as `RunWorkflow`, lives in the main agent's tool pool. You may explicitly ask to "run this workflow," invoke a saved `/command`, or let the model enter a high-intensity path automatically. In each case, the model emits a `Workflow(...)` tool call.
`Workflow` lives in the main agent's tool pool. The user can request a saved workflow, or the model can select the tool when a task matches a known orchestration. In either case, the model emits one `Workflow(...)` tool call.
The tool parses the arguments, validates metadata, checks permissions, registers a local workflow task, and immediately returns "started asynchronously." The main loop does not block and can continue with other work while the workflow runs in the background. This is the claim-ticket pattern from s13 at a larger scale: hand over the ticket now, notify the user when the result is ready.
@@ -60,11 +58,9 @@ class WorkflowTool:
... # The rest proceeds in the background
```
> The real Claude Code immediately returns `{status:'async_launched', taskId, taskType:'local_workflow', runId, summary, transcriptDir, scriptPath}`, then sends a notification when the background task finishes.
## Workflow Metadata: Validate Before Launch
## Script and Meta: The First Line Must Be Correct
The script's first line must be `export const meta = { name, description, phases }`, and it must contain only literals: no variables, function calls, or string concatenation. The runtime parses it before executing any code. `name` and `description` identify the task in the UI, while `phases` names groups in the progress display.
Each workflow registers a metadata object with `name`, `description`, and optional `phases`. The runtime validates it before executing any workflow code. `name` and `description` identify the task in the UI, while `phases` names groups in the progress display.
Invalid input raises `WorkflowInputError` immediately and is rejected during registration. This is the same idea as validating cron expressions in s14: do not wait until execution to discover a bad script.
@@ -86,8 +82,6 @@ def validate_meta(meta):
return meta
```
> The real Claude Code's `parseWorkflowScript` requires meta to be the first line and a pure literal. The teaching version accepts a dict directly to simplify this part.
## Orchestration Primitives: A Small Set Is Enough for Every Flow
A script runs in an isolated context with only a small set of orchestration primitives as globals. The script does not read files or run shell commands directly. All real code operations are performed by dispatched subagents under their own tool permissions. These primitives are methods on `ExecutionState`:
@@ -113,8 +107,6 @@ async def pipeline(self, items, *stages):
return await asyncio.gather(*[run_item(it, i) for i, it in enumerate(items)])
```
> The real Claude Code injects same-named primitives into the script VM. It also exposes `args`, `budget` with total/spent/remaining values, an agent limit of up to 1000, and a concurrency semaphore.
## Structured Output: Do Not Let Subagents Return Essays
`agent({schema})` requires a subagent to return a JSON object matching the schema, internally through one structured-output call. The runtime validates the result and retries once if it does not match. Downstream code receives a regular object instead of a long essay that must be parsed again.
@@ -132,8 +124,6 @@ if schema is not None:
raise WorkflowInputError(f"agent({{schema}}) returned invalid output: {err}")
```
> The real Claude Code combines `SimpleJsonSchema`, a `StructuredOutput` tool, and schema-aware retries to enforce the output format.
## Background Tasks and Progress Events
`LocalWorkflowTask` maintains status and token usage and emits an SDK-style event stream: `task_started` → a sequence of `task_progress` events containing phase changes, subagent starts, and log batches → one final `task_notification` reporting completion, failure, or stop, plus output files, token count, tool calls, and elapsed time.
@@ -147,11 +137,9 @@ class LocalWorkflowTask:
print(f" progress {ptype} ...")
```
> The real Claude Code folds progress into task state and sends it to the UI and SDK as `task_progress.workflow_progress`.
## Storage: Snapshot + Journal for Resuming after Interruptions
Each run writes five artifacts under `~/.claude/projects/<project>/<session>/`: a `<runId>.json` snapshot, `<runId>.output.json` output, `<runId>.journal.jsonl` journal, a `scripts/<runId>.js` script copy, and subagent transcripts under `subagents/workflows/<runId>/`. Reusable workflows that you save live in `.claude/workflows/` at project scope or `~/.claude/workflows/` at user scope.
The minimal runtime stores each run under `s21_workflow_runtime/.runtime/`: a `<runId>.json` snapshot, `<runId>.output.json` output, and `<runId>.journal.jsonl` journal. A production harness may also persist the workflow script and subagent transcripts, but the key requirement is that the snapshot and journal share a stable `runId`.
The journal is the core of checkpointed resume. It records every `agent()` result one line at a time:
@@ -165,7 +153,7 @@ class WorkflowJournal:
## Resume: Continue by runId and Reuse Everything Unchanged
Calling `Workflow({scriptPath, resumeFromRunId, args})` reruns the script, but every `agent()` computes a deterministic semantic key. If that key is present in the journal, it returns the cached result without executing again. Every unchanged call hits the cache; only a changed call and the downstream steps that depend on it actually rerun.
Calling the workflow again with `resume_from_run_id` reruns the script, but every `agent()` computes a deterministic semantic key. If that key is present in the journal, it returns the cached result without executing again. Every unchanged call hits the cache; only a changed call and the downstream steps that depend on it actually rerun.
The key detail is that keys cannot depend on concurrency order. Agents in `parallel` and `pipeline` finish in nondeterministic order. If "the nth completion" became the key, cache entries would map to the wrong calls on the next run. A key therefore uses a stable hash of call content, including type, label, prompt, and schema, rather than a shared counter:
@@ -181,11 +169,9 @@ if cached is not MISS:
return cached
```
> The real Claude Code uses the same idea: deterministic semantic keys plus a journal cache. Resuming within the same session returns cached results for completed `agent()` calls and runs only the remaining ones.
## Determinism: Reproducibility Makes Resume Meaningful
Resume works only if the script is reproducible. The runtime therefore removes nondeterministic sources such as `Date.now()`, no-argument `new Date()`, and `Math.random()` from the script context, and does not expose native Node APIs. The same script plus the same arguments produces the same keys and a 100% cache hit. The teaching version obtains the same property through stable key hashing; the real version runs the entire JavaScript inside a sandboxed VM with those sources removed.
Resume works only if the workflow is reproducible. The minimal Python runtime uses stable hashes and a deterministic mock runner, so the same workflow plus the same arguments produces the same keys. A production harness should also isolate workflow code and remove uncontrolled clocks, randomness, filesystem access, and other sources of nondeterminism.
## See It Run

View File

@@ -8,9 +8,7 @@ s01 → ... → s19 → s20 → `s21` → [s22](../s22_goal_loop/)
>
> **Harness 层**: 编排 — 在单 agent 循环之上,加一层确定的多 agent 脚本运行时。
> **来源边界:** 本章产品细节来自对 Claude Code 2.1.177 的 clean-room 行为重建。后续版本可能更改名称与限制;`code.py` 是离线教学模型,不是产品源码复制
>
> 教学 CLI 会先发出 `async_launched`,随后在同一进程等待完成,以保证输出可复现。它演示的是生命周期与 journal不是并发运行的主循环。
`code.py` 为了让演示保持确定,会先发出 `async_launched`,随后在同一进程里等待执行完成。这样不用启动常驻后台服务,也能看清生命周期和 journal
---
@@ -26,9 +24,9 @@ s01 → ... → s19 → s20 → `s21` → [s22](../s22_goal_loop/)
## 计划写在代码里,不是靠聊天一轮轮凑
Claude Code 在工具池里放了一个 `Workflow` 工具。你(或者模型在高强度模式下触发)给它一段脚本,脚本用 `agent() / parallel() / pipeline() / phase()` 这几个简单的原语,把编排写成确定的代码。
在 harness 的工具池里加入一个 `Workflow` 工具。用户或模型给它一段脚本,脚本用 `agent() / parallel() / pipeline() / phase()` 这几个简单的原语,把编排写成确定的代码。
主循环这边只看到一次 `tool_use`,立刻拿到"已在后台启动"的返回:真正的执行在后台运行时里推进,实时上报进度,所有过程都写到磁盘的 journal 文件里。脚本里的中间结果存在变量里,不会塞进对话历史占地方。下次用 `resumeFromRunId` 重启时,没改过的 `agent()` 直接命中 journal 缓存,直接用之前的结果,断点续跑。
主循环这边只看到一次 `tool_use`,立刻拿到"已在后台启动"的返回:真正的执行在后台运行时里推进,实时上报进度,所有过程都写到磁盘的 journal 文件里。脚本里的中间结果存在变量里,不会塞进对话历史占地方。下次用 `resume_from_run_id` 重启时,没改过的 `agent()` 直接命中 journal 缓存,直接用之前的结果,断点续跑。
![Workflow Runtime 总览](images/workflow-runtime-overview.svg)
@@ -45,7 +43,7 @@ async def sample_workflow(ctx, args):
## Workflow 工具:后台启动,主循环只看到一次调用
`Workflow`(别名 `RunWorkflow`)就在主 agent 的工具池里。触发可能来自你显式说"跑一下这个 workflow"、一个保存好的 `/命令`,或者模型自动进入高强度路径,这时候模型会发一个 `Workflow(...)` 工具调用。
`Workflow` 就在主 agent 的工具池里。用户可以要求运行一个保存好的 workflow模型也可以在任务匹配已知编排时选择这个工具两种情况最终都只发出一次 `Workflow(...)` 工具调用。
工具收到后会解析参数、校验 meta 信息、过权限检查、注册一个本地 workflow 任务,然后立刻返回"已异步启动"。主循环不阻塞该干嘛干嘛workflow 自己在后台跑。这其实就是 s13 后台任务那套"凭条模式"的放大版:先给你个取件条,结果好了再通知你。
@@ -60,11 +58,9 @@ class WorkflowTool:
... # 剩下的后台慢慢跑
```
> 真实 Claude Code工具会立刻返回 `{status:'async_launched', taskId, taskType:'local_workflow', runId, summary, transcriptDir, scriptPath}`,后台任务跑完了再通知。
## Workflow 元数据:启动前先校验
## 脚本和 meta第一行必须写对
脚本的第一行必须是 `export const meta = { name, description, phases }`,而且必须是纯字面量,不能有变量、函数调用、字符串拼接。运行时在执行任何代码之前先解析它:`name``description` 用来显示任务和 UI`phases` 给进度条分组命名。
每个 workflow 都要注册一个元数据对象,包含 `name``description` 和可选的 `phases`。运行时会在执行任何 workflow 代码之前校验它:`name``description` 用来标识任务,`phases` 给进度条分组命名。
不对的输入直接抛 `WorkflowInputError`,注册的时候就拦住——这和 s14 校验 cron 表达式是一个思路:坏脚本别让它跑到执行的时候才炸。
@@ -86,8 +82,6 @@ def validate_meta(meta):
return meta
```
> 真实 Claude Code`parseWorkflowScript` 强制 meta 必须是第一行且是纯字面量;教学版直接收一个 dict简化了这部分。
## 编排原语:就这几个,够写所有流程
脚本跑在一个独立的上下文里,能用的全局变量就这几个编排原语。脚本本身不直接读写文件、不跑 shell真正的代码操作都由派出去的子 agent 用它们自己的工具权限完成。这些原语都是 `ExecutionState` 上的方法:
@@ -113,8 +107,6 @@ async def pipeline(self, items, *stages):
return await asyncio.gather(*[run_item(it, i) for i, it in enumerate(items)])
```
> 真实 Claude Code同名原语由 VM 注入脚本上下文;还提供 `args`、`budget`(总预算/已花/剩余、agent 数量上限(最多 1000 个)、并发信号量这些控制。
## 结构化输出:别让子 agent 回来写散文
`agent({schema})` 会强制子 agent 返回一个匹配 schema 的 JSON 对象(内部通过一次结构化输出调用实现),运行时会按 schema 校验结果,不对就重试一次。这样下游代码拿到的是规整的对象,不是需要再解析的一大段散文。
@@ -132,8 +124,6 @@ if schema is not None:
raise WorkflowInputError(f"agent({{schema}}) 输出不合法: {err}")
```
> 真实 Claude Code用 `SimpleJsonSchema` + `StructuredOutput` 工具 + schema 重试机制保证输出格式。
## 后台任务和进度事件
`LocalWorkflowTask` 维护状态和 token 用量,向外发一条 SDK 风格的事件流:`task_started` → 一串 `task_progress`(包含阶段切换、子 agent 启动、日志输出这些批次)→ 最后一个 `task_notification`(完成/失败/停止带输出文件、token 数、工具调用数、耗时)。
@@ -147,11 +137,9 @@ class LocalWorkflowTask:
print(f" 进度 {ptype} ...")
```
> 真实 Claude Code进度会折叠进任务状态作为 `task_progress.workflow_progress` 发给 UI 和 SDK。
## 存储:快照 + journal断了能续
跑完会写五样东西,都存在 `~/.claude/projects/<项目>/<会话>/` 目录下:快照 `<runId>.json`、输出 `<runId>.output.json`journal `<runId>.journal.jsonl`、脚本副本 `scripts/<runId>.js`、子 agent 的对话记录 `subagents/workflows/<runId>/`。你自己保存的常用 workflow 放在 `.claude/workflows/`(项目级)或 `~/.claude/workflows/`(用户级)
这个最小运行时把每次运行的数据存在 `s21_workflow_runtime/.runtime/`:快照 `<runId>.json`、输出 `<runId>.output.json`journal `<runId>.journal.jsonl`。生产级 harness 还可以保存 workflow 脚本与子 agent 对话记录,但关键约束是快照和 journal 必须共享稳定的 `runId`
journal 是断点续跑的核心,它一条一条记下来每个 `agent()` 的结果:
@@ -165,7 +153,7 @@ class WorkflowJournal:
## resume用 runId 续跑,没改的直接用缓存
调用 `Workflow({scriptPath, resumeFromRunId, args})` 会重新跑脚本,但每个 `agent()` 算一个确定的语义 keykey 在 journal 里有记录,就直接返回缓存结果(不重跑),没改过的全部命中缓存;只有改过的那个以及它后面的步骤才会真的
带着 `resume_from_run_id` 再次调用 workflow 时,脚本会重新执行,但每个 `agent()` 都会计算一个确定的语义 keykey 在 journal 里有记录,就直接返回缓存结果;只有改过的调用以及依赖它的后续步骤才会真的运行
这里有个关键点key 不能依赖并发顺序。`parallel``pipeline` 里 agent 完成的顺序是不确定的,用"第几个完成"当 key两次跑缓存就对错位了。所以 key 是根据调用内容类型、标签、prompt、schema算的稳定哈希不是一个会竞争的计数器
@@ -181,11 +169,9 @@ if cached is not MISS:
return cached
```
> 真实 Claude Code同样是"确定语义 key + journal 缓存"的思路;同会话内续跑时,已经完成的 `agent()` 直接返回缓存,后面的才实跑。
## 确定性:能复现,续跑才有意义
续跑要能工作,脚本首先得可复现。所以运行时会把 `Date.now()`、无参 `new Date()``Math.random()` 这些不确定的东西从脚本上下文里去掉,也不给 Node 原生 API。同一份脚本 + 同样的参数同样的 key → 100% 缓存命中。教学版用稳定哈希算 key 达到同样的效果(真实版是把整段 JS 脚本跑在去掉了这些不确定源的沙箱 VM 里)
续跑要能工作,workflow 首先得可复现。这个最小 Python 运行时使用稳定哈希和确定性的 mock runner让同一份 workflow + 同样的参数产生同样的 key。生产级 harness 还应该隔离 workflow 代码,并移除不受控的时钟、随机数、文件系统访问等不确定来源
## 跑起来看看

View File

@@ -1,9 +1,5 @@
"""
s21_workflow_runtime — Dynamic Workflow runtime (teaching version)
Clean-room behavioral reconstruction of Claude Code's `Workflow` tool / dynamic
workflow runtime. Grounded in @anthropic-ai/claude-code@2.1.177 observed
behavior (reverse-research/cc_workflow), NOT leaked source.
s21_workflow_runtime — minimal dynamic Workflow runtime for a teaching harness
Idea:
s01-s20 build a single, model-driven agent loop. s21 adds a deterministic
@@ -16,14 +12,13 @@ Run:
python code.py # run the sample workflow, print the event stream
python code.py resume # resume the last run; unchanged agent() calls hit cache
Teaching simplifications (vs real runtime.mjs):
Implementation choices:
- The "subagent" is a deterministic MockAgentRunner, not a real LLM.
- A workflow is a plain async Python function, not a sandboxed JS script
string. The real runtime runs the script in an isolated JS VM with
Date.now()/Math.random() removed so resume is reproducible.
- A workflow is a plain async Python function. A production harness may use a
declarative format or run user-authored scripts in an isolated VM.
- The CLI emits `async_launched` and then awaits completion so the demo stays
deterministic. The real tool returns while execution continues in background.
- Storage is a local .runtime/ dir instead of ~/.claude/projects/.../workflows/.
deterministic. A long-running host can return while execution continues.
- Storage is a local .runtime/ directory beside this file.
"""
import asyncio
@@ -33,7 +28,7 @@ import re
import sys
from pathlib import Path
# ---- knobs that mirror the real runtime's guards ----
# ---- runtime guards ----
AGENT_CAP = 1000 # hard cap on agent() calls per run
CONCURRENCY = 8 # parallelism cap (semaphore)
STORE = Path(__file__).parent / ".runtime" # snapshots + journals live here
@@ -50,7 +45,7 @@ def _stable_hash(s: str) -> int:
def create_run_id(meta) -> str:
# Deterministic in the teaching version so the journal path is predictable
# and `resume` lands on the same file. The real runtime mints a random id.
# and `resume` lands on the same file.
return f"wf_{meta['name']}_{_stable_hash(meta['name']) % 10000:04d}"
@@ -68,15 +63,14 @@ def validate_run_id(run_id):
# Errors
# ============================================================
class WorkflowInputError(Exception):
"""Bad script / meta / schema input (mirrors WorkflowInputError)."""
"""Bad workflow, metadata, or schema input."""
# ============================================================
# meta validation
# ============================================================
def validate_meta(meta):
"""Real runtime requires `export const meta = {...}` as the FIRST statement,
a pure literal, with name + description (+ optional phases). We take a dict."""
"""Validate name, description, and optional phases before launch."""
if not isinstance(meta, dict):
raise WorkflowInputError("meta must be an object literal")
if not meta.get("name") or not meta.get("description"):
@@ -251,7 +245,7 @@ class WorkflowJournal:
# ============================================================
class Budget:
"""budget.total / spent() / remaining(). Once spent reaches total, agent()
calls raise (the real runtime enforces the same ceiling)."""
calls raise instead of silently overspending."""
def __init__(self, total=None):
self.total = total
@@ -313,8 +307,7 @@ class ExecutionLimits:
class ExecutionState:
"""Injected into the workflow script. Provides the orchestration primitives.
Mirrors ExecutionState in runtime.mjs."""
"""Injected into the workflow script with the orchestration primitives."""
def __init__(self, task, journal, runner, budget, args, depth=0, limits=None):
self.task = task
@@ -415,9 +408,8 @@ class ExecutionState:
# ============================================================
class WorkflowTool:
"""The Workflow tool. .call() validates meta, runs the permission check,
creates runId/taskId, registers a LocalWorkflowTask, and emits the same
lifecycle while this teaching CLI awaits the final result. Supports
resumeFromRunId. Mirrors WorkflowTool.call in runtime.mjs."""
creates runId/taskId, registers a LocalWorkflowTask, and emits lifecycle
events while this teaching CLI awaits the final result. Supports resume."""
async def call(self, meta, script_fn, args=None, resume_from_run_id=None):
validate_meta(meta)
@@ -479,7 +471,6 @@ def _read_last_run():
# ============================================================
# Sample workflow: review changed code across dimensions, verify each finding.
# Mirrors cc_workflow/runtime/workflows/review_workflow.js (pipeline + parallel).
# ============================================================
FINDINGS_SCHEMA = {
"type": "object", "required": ["findings"],
@@ -532,7 +523,7 @@ async def sample_workflow(ctx, args):
return {"confirmed": confirmed}
# saved workflow registry (.claude/workflows/ analogue)
# Saved workflow registry
WORKFLOWS = {SAMPLE_META["name"]: (SAMPLE_META, sample_workflow)}

View File

@@ -40,7 +40,7 @@
<!-- Workflow tool_use -->
<rect x="284" y="130" width="196" height="52" rx="6" fill="#ffffff" stroke="#1a1a1a" stroke-width="1.5"/>
<text x="382" y="151" text-anchor="middle" fill="#1a1a1a" font-size="11" font-weight="700" font-family="monospace">Workflow({script, args})</text>
<text x="382" y="169" text-anchor="middle" fill="#888888" font-size="8.5" font-family="monospace">(or name | scriptPath) · resumeFromRunId</text>
<text x="382" y="169" text-anchor="middle" fill="#888888" font-size="8.5" font-family="monospace">(or name | script) · resume_from_run_id</text>
<line x1="480" y1="156" x2="518" y2="156" stroke="#22c55e" stroke-width="2" marker-end="url(#arrow-green)"/>
<!-- tool_result -->
@@ -104,7 +104,7 @@
<!-- Journal -> Script VM: resume cached -->
<path d="M 662 449 L 500 449 L 500 382" fill="none" stroke="#888888" stroke-width="1.5" stroke-dasharray="5,3" marker-end="url(#arrow-gray)"/>
<text x="556" y="443" text-anchor="middle" fill="#888888" font-size="9" font-weight="600">resumeFromRunId -&gt; cached agent()</text>
<text x="556" y="443" text-anchor="middle" fill="#888888" font-size="9" font-weight="600">resume_from_run_id -&gt; cached agent()</text>
<!-- ===== Cross arrows between lanes ===== -->
<!-- launch (outer Workflow tool_use -> inner runtime), left side -->

Before

Width:  |  Height:  |  Size: 9.1 KiB

After

Width:  |  Height:  |  Size: 9.1 KiB

View File

@@ -8,8 +8,6 @@ s01 → ... → s20 → s21 → `s22`
>
> **Harness 層**: Goal closure — turn 終端に program-controlled completion gate を追加します。
> **情報源の境界:** この章の製品詳細は Claude Code 2.1.177 の clean-room 行動再構成に基づく。後続リリースで名称や制限は変わり得る。`code.py` はオフライン教材モデルであり、製品ソースの複製ではない。
---
s01 から s21 まで、会話の 1 turn はどう終わったでしょうか。モデルが `tool_use` を出さなくなると、loop はそのまま `return` しました。one-shot task なら問題ありません。終わったら止まります。
@@ -20,7 +18,7 @@ s01 から s21 まで、会話の 1 turn はどう終わったでしょうか。
## /goal: 各 turn の終端に gate を追加する
`/goal <condition>` を入力すると session-scoped stopping condition を設定します。program は active goal として保存し、各 turn の後に独立した lightweight model を evaluator として使い、transcript 内の trusted evidence condition を満たすか確認します。不足なら gate が停止を拒み、次ラウンドへ「作業を続ける」prompt を queue します。十分なら goal を消して complete とします。
`/goal <condition>` を入力すると session-scoped stopping condition を設定します。program は active goal として保存し、各 turn の後に evaluator transcript 内の trusted evidence condition と照合します。不足なら gate が停止を拒み、次ラウンドへ「作業を続ける」prompt を queue します。十分なら goal を消して complete とします。
![Goal Loop Overview](images/goal-loop-overview.svg)
@@ -40,8 +38,6 @@ if not has_tool_use(response):
この gate を制御するのは program です。モデルが自分を律しているのではありません。モデルは gate の存在すら知らず、次のラウンドの入力を受け取って作業を続けるだけです。
> 実際の Claude Code では `/goal` は session-scoped Stop hook で、workspace trust と hook restriction の管理下にあります。コードには `active_goal`、`goal_status`、`goal_met`、`tengu_goal_achieved` などの marker があります。
## Goal の設定: Evidence は command の後から数える
`set_goal` は active goal として、goal text、最大 turn budget、counter、そして evidence window の開始点 `start_index` を保存します。現在の transcript length を使うため、`/goal` command 自身は window の外です。これが最初の防御です。command が自分自身の完了を証明することはできません。
@@ -55,8 +51,6 @@ def set_goal(self, objective, max_turns=20):
}
```
> 実際の Claude Code では `GoalRuntime.setGoal()` が active goal、開始位置、counter、budget を保存し、submit 後に `resetEvidenceStart()` で window を command 後へそろえます。
## Evaluator: 実在する evidence だけを信頼する
ここが仕組み全体の core です。evaluator は会話全体を見ず、evidence window 内で trusted source から来た message だけを見ます。3 層の filter が、「完了したと言ったから完了」という内容をすべて外へ止めます。
@@ -79,9 +73,7 @@ def evidence_text(self):
効果は明確です。同じ `tests passed` でも、あなたが入力したものは数えず、background task notification が持ち帰ったものだけを数えます。モデルは「完了した」と自分で言うだけでは goal を complete にできません。これはコース全体に繰り返し現れた trust boundary の最後の登場です。s16 は protocol が理解ではなく field に依存すると言い、s19 は annotation が申告であり、申告は嘘をつけると言い、s22 は completion evidence を content ではなく origin で信頼します。
教材版の `goal_satisfied()` は決定的な keyword matching です。実際の版は evidence window を別の lightweight model へ渡して判定します。
> 実際の Claude Code の evaluator は作業モデルとは別の lightweight model で、`evaluatorModel`、`default small fast model` と記されています。任意の text を信じず、会話内の evidence を判断します。
最小版の `goal_satisfied()` は決定的な keyword matching を使い、demo を offline かつ再現可能に保ちます。production harness では、この policy を独立した lightweight evaluator model に置き換えられますが、trusted evidence boundary はそのまま維持します。
## Gate の 3 状態: Completed / continuing / budget 超過
@@ -106,8 +98,6 @@ def evaluate_after_turn(self):
continuation prompt には、わざわざ自身を evidence にしないよう書き、filter でも除外します。これで false positive を防ぐ 3 層がそろいます。command text、reminder text、ordinary conversation のいずれも数えません。budget は s11 の古い規則に従います。automatic retry mechanism には必ず上限が必要です。そうでなければ、永遠に satisfied にならない goal が費用を燃やし続けます。
> 実際の Claude Code の `evaluateAfterTurn` は `goal_evaluated` event を出し、結果に応じて complete、continuation queue、gate の解除を行います。default budget は 20 turn です。
## Continuation prompt と外部 asynchronous message を分ける
continuation prompt は同じ `CommandQueue` に入りますが、task completion notification や monitor line といった外部 asynchronous event とは別の方法で消費します。`dequeue` には switch があり、外部 inbox を消費するときは goal continuation を既定で skip します。
@@ -121,9 +111,7 @@ def dequeue(self, include_goal_continuations=True):
return None
```
なぜ分けるのでしょう。実際の model test では、モデルが continuation prompt 外部 notification 一緒に消費し、background evidence が到着する前に goal を complete と判定する bug が起きました。分離後は goal の進行が明示的な 1 step になり、asynchronous event に偶然運ばれません。
> 実際の Claude Code の `drainCommandQueue` は既定で `includeGoalContinuations=false` とし、goal continuation の消費を外部 asynchronous inbox から分けます。
なぜ分けるのでしょう。同じ consumer が continuation prompt 外部 notification 一緒に取り出すと、background result が届く前に reminder text を新しい evidence と誤認する可能性があります。分離後は goal の進行が明示的な 1 step になり、asynchronous event に偶然運ばれません。
## 実際に動かす

View File

@@ -8,8 +8,6 @@ s01 → ... → s20 → s21 → `s22`
>
> **Harness layer**: Goal closure — a program-controlled completion gate at the end of each turn.
> **Source boundary:** Product details in this chapter are a clean-room behavioral reconstruction of Claude Code 2.1.177. Names and limits may change in later releases; `code.py` is an offline teaching model, not copied product source.
---
From s01 through s21, 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.
@@ -20,7 +18,7 @@ This thread was present from the first chapter. s01 explained that exiting the l
## /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 independent lightweight model acts as evaluator and 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.
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.
![Goal Loop Overview](images/goal-loop-overview.svg)
@@ -40,8 +38,6 @@ if not has_tool_use(response):
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.
> In the real Claude Code, `/goal` is a session-scoped Stop hook governed by workspace trust and hook restrictions. The code contains markers such as `active_goal`, `goal_status`, `goal_met`, and `tengu_goal_achieved`.
## 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.
@@ -55,8 +51,6 @@ def set_goal(self, objective, max_turns=20):
}
```
> In the real Claude Code, `GoalRuntime.setGoal()` stores the active goal, start position, counters, and budget, then `resetEvidenceStart()` aligns the window to the position after command submission.
## 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:
@@ -79,9 +73,7 @@ def evidence_text(self):
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. s16 said protocols rely on fields, not interpretation. s19 said annotations are claims and claims may be false. s22 says completion evidence is trusted by origin, not by content alone.
The teaching version's `goal_satisfied()` uses deterministic keyword matching. The real version asks a separate lightweight model to judge the evidence window.
> In the real Claude Code, the evaluator is a lightweight model separate from the working model, marked as `evaluatorModel` and the `default small fast model`. It judges evidence in the conversation rather than trusting arbitrary text.
The minimal `goal_satisfied()` uses deterministic keyword matching so the demo stays offline and reproducible. A production harness can replace this policy with a separate lightweight evaluator model, while keeping the same trusted evidence boundary.
## Three Gate States: Completed, Continuing, or Over Budget
@@ -106,8 +98,6 @@ def evaluate_after_turn(self):
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.
> In the real Claude Code, `evaluateAfterTurn` emits a `goal_evaluated` event and either completes, queues a continuation, or stops blocking. The default budget is 20 turns.
## 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.
@@ -121,9 +111,7 @@ def dequeue(self, include_goal_continuations=True):
return None
```
Why separate them? A real model test exposed a bug where the model consumed the continuation prompt together with an external notification and marked the goal complete before background evidence arrived. With the paths separated, goal progression is an explicit step and cannot be carried along accidentally by asynchronous events.
> In the real Claude Code, `drainCommandQueue` defaults to `includeGoalContinuations=false`, separating goal-continuation consumption from the external asynchronous inbox.
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

View File

@@ -8,8 +8,6 @@ s01 → ... → s20 → s21 → `s22`
>
> **Harness 层**: 目标闭环 — 在轮次收尾处,加一道程序控制的完成闸门。
> **来源边界:** 本章产品细节来自对 Claude Code 2.1.177 的 clean-room 行为重建。后续版本可能更改名称与限制;`code.py` 是离线教学模型,不是产品源码复制。
---
从 s01 到 s21一轮对话怎么结束模型不再发 `tool_use`,循环就直接 `return` 了。一次性任务这么干没问题,做完就停。
@@ -20,7 +18,7 @@ s01 → ... → s20 → s21 → `s22`
## /goal每轮收尾加一道闸门
输入 `/goal <条件>` 就设了一个会话级的停止条件。程序把它存成当前活跃目标,每轮结束后,用一个独立的轻量小模型当判断器,看对话记录里的可信证据够不够满足条件。不够,闸门就把这次结束拦住,塞一条"继续干"的提示进下一轮;够了,就清除目标,标记完成。
输入 `/goal <条件>` 就设了一个会话级的停止条件。程序把它存成当前活跃目标,每轮结束后,判断器检查对话记录里的可信证据够不够满足条件。不够,闸门就把这次结束拦住,塞一条"继续干"的提示进下一轮;够了,就清除目标,标记完成。
![Goal Loop 总览](images/goal-loop-overview.svg)
@@ -40,8 +38,6 @@ if not has_tool_use(response):
这道闸门是程序自己控制的。不是模型自己约束自己,模型甚至不知道有这么一道闸门,它只是收到了下一轮的输入,接着干就是了。
> 真实 Claude Code`/goal` 是会话级的 Stop hook受工作区信任和 hook 限制控制;代码里有 `active_goal`、`goal_status`、`goal_met`、`tengu_goal_achieved` 这些标记。
## 设目标:证据从命令之后开始算
`set_goal` 会存一个活跃目标:目标文本、最大轮数预算、计数器,还有 `start_index`——也就是证据窗口的起点。它取当前对话记录的长度,所以 `/goal` 这行命令本身在窗口外面。这是第一道防线:命令自己不能证明自己完成了。
@@ -55,8 +51,6 @@ def set_goal(self, objective, max_turns=20):
}
```
> 真实 Claude Code`GoalRuntime.setGoal()` 存活跃目标、起始位置、计数器和预算;提交后再 `resetEvidenceStart()` 把窗口对齐到命令之后。
## 判断器:只信实打实的证据
这是整个机制最核心的地方。判断器不看整段对话,只看证据窗口里来自可信来源的消息。三层过滤,把"嘴上说完成了但不算数"的内容全挡在外面:
@@ -79,9 +73,7 @@ def evidence_text(self):
效果很明显:同样一句 `tests passed`,你打字说的不算,后台任务通知带回来的才算。模型糊弄不过去,它没法靠自己说一句"我做完了"就把目标判成完成。这是全课程反复出现的那条信任边界的最后一次登场s16 说协议靠字段不靠理解s19 说注解是申报、申报可以撒谎s22 说完成证据只看来源不看内容。
教学版里 `goal_satisfied()` 确定的关键词匹配;真实版会把证据窗口交给一个轻量小模型来判断
> 真实 Claude Code判断器是和干活的模型分开的轻量小模型标记是 `evaluatorModel`、`default small fast model`),判断对话里的证据,不是随便什么文本都信。
最小版的 `goal_satisfied()` 使用确定的关键词匹配,让演示保持离线和可复现。生产级 harness 可以把这条策略替换成独立的轻量判断模型,但仍然保留相同的可信证据边界
## 闸门三态:完成/继续/超预算
@@ -106,8 +98,6 @@ def evaluate_after_turn(self):
那条"继续干"的提示里特意写了"别把这条提醒当成完成证据",连提醒本身都被排除在证据之外。三层防误判就齐了:命令文本不算、提醒文本不算、普通聊天文本不算。预算则是 s11 教过的老规矩:任何自动重试的机制都得有上限,不然一个永远判不满足的目标就是个烧钱的永动机。
> 真实 Claude Code`evaluateAfterTurn` 会发 `goal_evaluated` 事件,按结果完成/塞继续提示/拦截;默认预算是 20 轮。
## 继续提示和外部异步消息分开走
继续提示进的是同一个 `CommandQueue`,但它和外部异步事件(任务完成通知、监控行)不是同一种消费方式。`dequeue` 带个开关:消费外部收件箱的时候,默认跳过目标的继续提示。
@@ -121,9 +111,7 @@ def dequeue(self, include_goal_continuations=True):
return None
```
为什么要分开?真实模型测试的时候出过一个 bug模型把继续提示当成外部通知一起消费了,结果后台证据还没到,就提前把目标判成完成了。分开之后,目标的推进是显式的一步,不会被异步事件带着走。
> 真实 Claude Code`drainCommandQueue` 默认 `includeGoalContinuations=false`,把目标继续提示和外部异步收件箱的消费分开。
为什么要分开?如果同一个消费者把继续提示外部通知一起取走,后台结果还没到,提醒文本就可能被误当成新证据。分开之后,目标的推进是显式的一步,不会被异步事件带着走。
## 跑起来看看

View File

@@ -1,9 +1,5 @@
"""
s22_goal_loop — /goal session goal loop (teaching version)
Clean-room behavioral reconstruction of Claude Code's `/goal` command. Grounded
in @anthropic-ai/claude-code@2.1.177 observed behavior
(reverse-research/cc_goal_loop), NOT leaked source.
s22_goal_loop — minimal /goal session loop for a teaching harness
Idea:
s01-s21 end a turn when the model emits no tool_use. `/goal` adds a
@@ -27,12 +23,12 @@ Idea:
Run:
python code.py # /goal until tests pass + deploy green; watch the gate
Teaching simplifications (vs real /goal and runtime.mjs):
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 faithful part: only task-notification /
- 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
@@ -68,7 +64,7 @@ class Message:
# ============================================================
# CommandQueue — continuation prompts live here (mirrors CommandQueue)
# CommandQueue — continuation prompts live here
# ============================================================
class CommandQueue:
PRIORITY = {"now": 0, "next": 1, "later": 2}
@@ -102,7 +98,7 @@ class CommandQueue:
# ============================================================
# GoalRuntime — the turn-completion gate (mirrors GoalRuntime)
# GoalRuntime — the turn-completion gate
# ============================================================
class GoalRuntime:
def __init__(self, transcript, queue):
@@ -148,9 +144,8 @@ class GoalRuntime:
return "\n".join(out)
def goal_satisfied(self):
# Real Claude Code routes this to a small/fast evaluator model reading
# the evidence window. The teaching version is a deterministic keyword
# check so the lifecycle is reproducible.
# A production harness can route this evidence window to a separate
# evaluator model. The demo uses a deterministic keyword policy.
objective = self.active["objective"].lower()
evidence = self.evidence_text().lower()
wants_tests = "test" in objective
@@ -193,7 +188,7 @@ class GoalRuntime:
# ============================================================
# Session — the main loop host with a Stop gate (mirrors submit / drain)
# Session — the main loop host with a Stop gate
# ============================================================
class Session:
def __init__(self):

View File

@@ -40,7 +40,7 @@
<!-- Workflow tool_use -->
<rect x="284" y="130" width="196" height="52" rx="6" fill="#ffffff" stroke="#1a1a1a" stroke-width="1.5"/>
<text x="382" y="151" text-anchor="middle" fill="#1a1a1a" font-size="11" font-weight="700" font-family="monospace">Workflow({script, args})</text>
<text x="382" y="169" text-anchor="middle" fill="#888888" font-size="8.5" font-family="monospace">(or name | scriptPath) · resumeFromRunId</text>
<text x="382" y="169" text-anchor="middle" fill="#888888" font-size="8.5" font-family="monospace">(or name | script) · resume_from_run_id</text>
<line x1="480" y1="156" x2="518" y2="156" stroke="#22c55e" stroke-width="2" marker-end="url(#arrow-green)"/>
<!-- tool_result -->
@@ -104,7 +104,7 @@
<!-- Journal -> Script VM: resume cached -->
<path d="M 662 449 L 500 449 L 500 382" fill="none" stroke="#888888" stroke-width="1.5" stroke-dasharray="5,3" marker-end="url(#arrow-gray)"/>
<text x="556" y="443" text-anchor="middle" fill="#888888" font-size="9" font-weight="600">resumeFromRunId -&gt; cached agent()</text>
<text x="556" y="443" text-anchor="middle" fill="#888888" font-size="9" font-weight="600">resume_from_run_id -&gt; cached agent()</text>
<!-- ===== Cross arrows between lanes ===== -->
<!-- launch (outer Workflow tool_use -> inner runtime), left side -->

Before

Width:  |  Height:  |  Size: 9.1 KiB

After

Width:  |  Height:  |  Size: 9.1 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long