mirror of
https://github.com/shareAI-lab/analysis_claude_code.git
synced 2026-09-21 21:03:38 +08:00
feat: refresh course through workflow and goal loops
This commit is contained in:
242
s21_workflow_runtime/README.ja.md
Normal file
242
s21_workflow_runtime/README.ja.md
Normal file
@@ -0,0 +1,242 @@
|
||||
# s21: Workflow Runtime — モデルが単一 step を決め、script が orchestration を決める
|
||||
|
||||
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
|
||||
|
||||
s01 → ... → s19 → s20 → `s21` → [s22](../s22_goal_loop/)
|
||||
|
||||
> *「1 回の tool_use で、バックグラウンドに一式の orchestration を走らせる」* — `Workflow` ツールが決定的で復元可能な script runtime を起動し、多数の subagent をまとめて送り出します。
|
||||
>
|
||||
> **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 の並行実行そのものではない。
|
||||
|
||||
---
|
||||
|
||||
s01 から s20 まで、loop は常にモデル駆動で 1 step ずつ進みました。各ラウンドでモデルが 1 つのツールを選び、結果を `messages[]` へ入れ、次のラウンドへ進みます。open-ended なタスクには最適です。次に何をするかを、モデルが context を見てその場で決められます。
|
||||
|
||||
しかし、複数の Agent を決定的に指揮したい仕事もあります。大きな変更の review を考えてください。10 の観点から並行して問題を探す → 各 finding へ別 Agent を送り adversarial verification を行う → 結果を集約して重複を除く → severity 順に並べる。この流れの形は固定されており、本当に必要なのは 3 つです。
|
||||
|
||||
- **並行性**: 1 件ずつ順番に待たないこと。
|
||||
- **決定性**: 同じ入力から同じ結果構造が得られること。
|
||||
- **復元可能性**: 途中で止まっても、完了済みの部分を最初からやり直さないこと。
|
||||
|
||||
この流れをモデルに main loop で 1 ラウンドずつ動かさせると、遅く、結果は不確定で、中断すれば最初からです。ここで必要なのは「もう 1 turn 話す」ことではなく、orchestration をそのままコードにすることです。
|
||||
|
||||
## 計画は chat のラウンドを重ねず、コードに書く
|
||||
|
||||
Claude Code の tool pool には `Workflow` ツールがあります。あなたが渡すか、モデルが high-intensity mode で起動した script は、`agent() / parallel() / pipeline() / phase()` という少数の primitive を使い、orchestration を決定的なコードとして表します。
|
||||
|
||||
main loop から見えるのは 1 回の `tool_use` だけで、すぐ「バックグラウンドで起動済み」という結果を受け取ります。本当の実行は background runtime で進み、進捗をリアルタイムに報告し、全過程をディスク上の journal へ記録します。script の中間結果は変数に保存され、会話履歴の場所を取りません。`resumeFromRunId` で再開すると、変更されていない `agent()` は journal cache に当たり、以前の結果を直接使って checkpoint から続行します。
|
||||
|
||||

|
||||
|
||||
```python
|
||||
SAMPLE_META = {"name": "review-changes", "description": "コード変更を review", "phases": ["Review", "Verify"]}
|
||||
|
||||
async def sample_workflow(ctx, args):
|
||||
ctx.phase("Review")
|
||||
results = await ctx.pipeline(DIMENSIONS, audit, verify) # 各 dimension が独立して audit → verify を通る
|
||||
confirmed = [f for r in results if r for f in r["confirmed"]]
|
||||
ctx.log(f"{len(confirmed)} 件の実在する問題を確認")
|
||||
return {"confirmed": confirmed}
|
||||
```
|
||||
|
||||
## Workflow ツール: バックグラウンド起動、main loop には 1 回の call だけ
|
||||
|
||||
`Workflow`(別名 `RunWorkflow`)は main Agent の tool pool にあります。明示的に「この workflow を実行」と頼む、保存済みの `/command` を使う、またはモデルが自動で high-intensity path へ入ると、モデルが `Workflow(...)` の tool call を出します。
|
||||
|
||||
ツールは argument を parse し、meta 情報を検証し、permission check を通し、local workflow task を登録すると、すぐ「非同期で起動済み」と返します。main loop は block せず別の仕事を続け、workflow は background で実行されます。これは s13 の引換券 pattern を拡大したものです。先に引換券を渡し、結果ができたら通知します。
|
||||
|
||||
```python
|
||||
class WorkflowTool:
|
||||
async def call(self, meta, script_fn, args=None, resume_from_run_id=None):
|
||||
validate_meta(meta)
|
||||
check_permission(meta)
|
||||
run_id = resume_from_run_id or create_run_id(meta)
|
||||
task = LocalWorkflowTask(create_task_id(run_id), run_id, meta)
|
||||
task.event("async_launched", runId=run_id, taskId=task.task_id) # すぐ return
|
||||
... # 残りはバックグラウンドで進む
|
||||
```
|
||||
|
||||
> 実際の Claude Code は `{status:'async_launched', taskId, taskType:'local_workflow', runId, summary, transcriptDir, scriptPath}` をすぐ返し、background task の完了後に通知します。
|
||||
|
||||
## Script と meta: 1 行目を正しく書く
|
||||
|
||||
script の 1 行目は必ず `export const meta = { name, description, phases }` とし、変数、関数呼び出し、文字列連結を含まない純粋な literal でなければなりません。runtime はコードを一切実行する前に parse します。`name` と `description` は task と UI の表示に使い、`phases` は progress bar の group 名を定義します。
|
||||
|
||||
不正な入力はすぐ `WorkflowInputError` になり、登録時に止まります。s14 の cron 式検証と同じ考えです。不正な script が実行時まで進んでから壊れないようにします。
|
||||
|
||||
教材 runtime は `meta.name` をローカル artifact のファイル名に使うため、英数字で始まり、英数字、`.`、`_`、`-` のみからなる 1-64 文字の安全な slug も要求する。
|
||||
|
||||
```python
|
||||
def validate_meta(meta):
|
||||
if not isinstance(meta, dict):
|
||||
raise WorkflowInputError("meta は object literal でなければなりません")
|
||||
if not meta.get("name") or not meta.get("description"):
|
||||
raise WorkflowInputError("meta には name と description が必要です")
|
||||
if not isinstance(meta["name"], str) or not WORKFLOW_NAME_RE.fullmatch(meta["name"]):
|
||||
raise WorkflowInputError("meta.name は安全な 1-64 文字の slug が必要です")
|
||||
if "phases" in meta and (
|
||||
not isinstance(meta["phases"], list)
|
||||
or not all(isinstance(p, str) and p for p in meta["phases"])
|
||||
):
|
||||
raise WorkflowInputError("meta.phases は空でない文字列だけを含む必要があります")
|
||||
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 です。
|
||||
|
||||
| Primitive | 役割 |
|
||||
|------|------|
|
||||
| `agent(prompt, {schema, label, phase})` | 1 つの subagent を派遣 |
|
||||
| `parallel(thunks)` | **barrier**: すべての task を並行実行し、全結果が戻るまで待つ |
|
||||
| `pipeline(items, *stages)` | 各 item を **barrier なし**で stage ごとに実行し、終わった item から先へ進める |
|
||||
| `phase(title)` | 現在の progress phase を記録し、progress bar を更新 |
|
||||
| `log(message)` | progress log を 1 行出力 |
|
||||
| `workflow(name, args)` | nested sub-workflow(1 階層だけ) |
|
||||
|
||||
既定では `pipeline` を使うべきです。各 item がすべての stage を独立して通り、item A が stage 3 にいる間、item B はまだ stage 1 かもしれません。次の stage へ進むために前 stage の全結果が本当に必要なときだけ、`parallel` barrier を使います。barrier は最も遅い task を待つため、不要なら置かないでください。
|
||||
|
||||
```python
|
||||
async def pipeline(self, items, *stages):
|
||||
async def run_item(item, idx):
|
||||
value = item
|
||||
for stage in stages: # 各 item がすべての stage を独立して完走
|
||||
value = await stage(value, item, idx)
|
||||
return value
|
||||
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 が必要な長文ではありません。
|
||||
|
||||
s05 では tool argument を全面的に信頼できないと説明しました。ここでは同じ教訓を逆向きに使います。subagent の出力も全面的には信頼できません。orchestration boundary で検証し、1 回 retry の機会を与え、不確実性を後続 flow の外へ止めます。
|
||||
|
||||
```python
|
||||
result = self.runner.run(prompt, schema, label)
|
||||
if schema is not None:
|
||||
ok, err = SimpleJsonSchema(schema).validate(result)
|
||||
if not ok: # 1 回だけ注意して retry、それでも不正なら error
|
||||
result = self.runner.run(prompt + "\n\n有効な JSON を返してください。", schema, label)
|
||||
ok, err = SimpleJsonSchema(schema).validate(result)
|
||||
if not ok:
|
||||
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` です。
|
||||
|
||||
main session は通常 event として処理し、最後の完了通知だけが main loop へ再び入ります。
|
||||
|
||||
```python
|
||||
class LocalWorkflowTask:
|
||||
def progress_event(self, ptype, **data): # phase/subagent/log
|
||||
self.progress.append({"type": ptype, **data})
|
||||
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/` に置きます。
|
||||
|
||||
journal は checkpoint resume の中心で、各 `agent()` の結果を 1 行ずつ記録します。
|
||||
|
||||
```python
|
||||
class WorkflowJournal:
|
||||
def record(self, key, value):
|
||||
self._f.write(json.dumps({"key": key, "value": value}) + "\n")
|
||||
self._f.flush()
|
||||
self.cache[key] = value
|
||||
```
|
||||
|
||||
## Resume: runId から続行し、変更のないものを再利用する
|
||||
|
||||
`Workflow({scriptPath, resumeFromRunId, args})` を呼ぶと script を再実行しますが、各 `agent()` は決定的な semantic key を計算します。journal に key があれば、再実行せず cached result を返します。変更のない call はすべて cache hit し、変更された call とそれに依存する後続 step だけが本当に動きます。
|
||||
|
||||
key は concurrency の完了順に依存してはいけません。`parallel` と `pipeline` の Agent は不定の順番で完了します。「何番目に完了したか」を key にすると、次回の cache が別の call へ対応してしまいます。そのため key は競合する counter ではなく、call の内容、つまり type、label、prompt、schema の stable hash です。
|
||||
|
||||
```python
|
||||
def key(self, kind, label, prompt, schema):
|
||||
basis = f"{kind}|{label}|{prompt}|{json.dumps(schema, sort_keys=True)}"
|
||||
return f"{kind}-{_stable_hash(basis) % 10**10:010d}"
|
||||
|
||||
# agent() の内部:
|
||||
cached = self.journal.cached(key)
|
||||
if cached is not MISS:
|
||||
self.task.progress_event("workflow_agent", label=label, status="cached")
|
||||
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 全体を実行します。
|
||||
|
||||
## 実際に動かす
|
||||
|
||||
sample workflow `review-changes` は `pipeline` を使い、各 review dimension を独立して audit → verify へ通します。audit では schema 付き `agent()` が問題を探し、verify では `parallel()` が各 finding に別の adversarial verification subagent を送ります。実在すると確認された問題だけを残し、severity 順に並べます。
|
||||
|
||||
```python
|
||||
async def sample_workflow(ctx, args):
|
||||
ctx.phase("Review")
|
||||
|
||||
async def audit(_v, dimension, _i):
|
||||
out = await ctx.agent(f"変更されたコードに {dimension} 関連の問題がないか確認してください",
|
||||
schema=FINDINGS_SCHEMA, label=f"audit:{dimension}", phase="Review")
|
||||
return {"dimension": dimension, "findings": out["findings"]}
|
||||
|
||||
async def verify(audited, dimension, _i):
|
||||
ctx.phase("Verify")
|
||||
verdicts = await ctx.parallel([ # 各 finding を独立して verify
|
||||
(lambda f=f: ctx.agent(f"この問題が実在するか adversarial に検証してください: {f['title']}",
|
||||
schema=VERDICT_SCHEMA, label=f"verify:{dimension}:{f['title']}"))
|
||||
for f in audited["findings"]])
|
||||
return {"dimension": dimension,
|
||||
"confirmed": [f for f, v in zip(audited["findings"], verdicts) if v and v["isReal"]]}
|
||||
|
||||
results = await ctx.pipeline(DIMENSIONS, audit, verify)
|
||||
...
|
||||
```
|
||||
|
||||
## s20 からの変更点
|
||||
|
||||
| | s20 Comprehensive Agent | s21 Workflow Runtime |
|
||||
|--|-----------|---------------------|
|
||||
| loop | 1 つ、モデル駆動 | main loop は不変。その上に決定的 orchestration を追加 |
|
||||
| 次の step を決めるもの | モデルが毎ラウンド判断 | script が orchestration flow を事前に定義 |
|
||||
| multi-agent | s06 subagent を一度だけ派遣 | script 化された、再現可能で復元可能な一括 orchestration |
|
||||
| 新しい仕組み | — | script DSL、background task、progress event、journal/resume、structured output、deterministic VM |
|
||||
|
||||
s21 は main loop を置き換えません。tool layer に `Workflow` を公開し、背後で local workflow runtime を起動します。1 つの workflow が N 個の Agent loop を決定的に駆動します。s06 の subagent はモデルがその場で 1 回派遣し、s21 は orchestration を replay 可能な script にします。
|
||||
|
||||
## 試してみる
|
||||
|
||||
```bash
|
||||
python s21_workflow_runtime/code.py # review-changes を起動し、event stream を確認
|
||||
python s21_workflow_runtime/code.py resume # 前回の runId から resume。すべての agent() が journal cache に当たる
|
||||
```
|
||||
|
||||
1 回の起動から `async_launched`、background の phase change と subagent progress、最後の `task_notification` までを観察してください。結果は task object に保存されます。resume 時はすべて cache hit するため `agents=0 tokens=0` と表示され、結果は前回と 1 byte も違いません。
|
||||
|
||||
## 次へ
|
||||
|
||||
orchestration は Agent 能力の上にもう 1 層を加えます。main loop は個々の操作を管理し、script はチーム全体の flow を管理します。仕事が決定的で復元可能な script になると、モデルは「ラウンドごとの driver」から「script に schedule される実行 unit」へ変わります。同じ `agent()` を main loop でモデルがその場で呼ぶことも、workflow 内で script がまとめて編成することもできます。
|
||||
|
||||
次へ: [s22 Goal Loop](../s22_goal_loop/) — Orchestration は仕事を fan-out し、main loop から離れます。次章は逆に、1 つの goal が control を main loop へ引き戻し、objective が達成されるまで turn の終了を認めません。
|
||||
|
||||
<!-- translation-sync: zh@v2, en@v2, ja@v2 -->
|
||||
242
s21_workflow_runtime/README.md
Normal file
242
s21_workflow_runtime/README.md
Normal file
@@ -0,0 +1,242 @@
|
||||
# s21: Workflow Runtime — The Model Decides Each Step; a Script Decides the Orchestration
|
||||
|
||||
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
|
||||
|
||||
s01 → ... → s19 → s20 → `s21` → [s22](../s22_goal_loop/)
|
||||
|
||||
> *"One tool_use starts an entire orchestration in the background"* — The `Workflow` tool starts a deterministic, recoverable script runtime that dispatches many subagents in bulk.
|
||||
>
|
||||
> **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.
|
||||
|
||||
---
|
||||
|
||||
From s01 through s20, our loop has always been model-driven and step-by-step: the model chooses one tool each round, its result enters `messages[]`, and another round begins. That is ideal for open-ended tasks because the model can inspect the current context and decide the next step on the spot.
|
||||
|
||||
Some jobs, however, require deterministic command of a group of agents. Consider reviewing a large change: inspect ten dimensions in parallel → send each finding to a separate agent for adversarial verification → combine and deduplicate the results → sort by severity. The shape is fixed, and you really need three properties:
|
||||
|
||||
- **Parallelism**, rather than waiting for one item at a time;
|
||||
- **Determinism**, so the same input produces the same result structure;
|
||||
- **Recoverability**, so an interruption does not rerun work that is already complete.
|
||||
|
||||
Making the model drive this process one round at a time in the main loop is slow and nondeterministic, and an interruption starts everything over. At that point, you do not need "one more conversation turn." You need to encode the orchestration directly as code.
|
||||
|
||||
## 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()`.
|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
```python
|
||||
SAMPLE_META = {"name": "review-changes", "description": "Review code changes", "phases": ["Review", "Verify"]}
|
||||
|
||||
async def sample_workflow(ctx, args):
|
||||
ctx.phase("Review")
|
||||
results = await ctx.pipeline(DIMENSIONS, audit, verify) # Each dimension independently runs audit → verify
|
||||
confirmed = [f for r in results if r for f in r["confirmed"]]
|
||||
ctx.log(f"Confirmed {len(confirmed)} real issues")
|
||||
return {"confirmed": confirmed}
|
||||
```
|
||||
|
||||
## 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.
|
||||
|
||||
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.
|
||||
|
||||
```python
|
||||
class WorkflowTool:
|
||||
async def call(self, meta, script_fn, args=None, resume_from_run_id=None):
|
||||
validate_meta(meta)
|
||||
check_permission(meta)
|
||||
run_id = resume_from_run_id or create_run_id(meta)
|
||||
task = LocalWorkflowTask(create_task_id(run_id), run_id, meta)
|
||||
task.event("async_launched", runId=run_id, taskId=task.task_id) # Return immediately
|
||||
... # 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.
|
||||
|
||||
## 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.
|
||||
|
||||
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.
|
||||
|
||||
Because the teaching runtime uses `meta.name` in local artifact filenames, it also requires a 1-64 character safe slug containing letters, numbers, `.`, `_`, or `-`.
|
||||
|
||||
```python
|
||||
def validate_meta(meta):
|
||||
if not isinstance(meta, dict):
|
||||
raise WorkflowInputError("meta must be an object literal")
|
||||
if not meta.get("name") or not meta.get("description"):
|
||||
raise WorkflowInputError("meta requires name and description")
|
||||
if not isinstance(meta["name"], str) or not WORKFLOW_NAME_RE.fullmatch(meta["name"]):
|
||||
raise WorkflowInputError("meta.name must be a safe 1-64 character slug")
|
||||
if "phases" in meta and (
|
||||
not isinstance(meta["phases"], list)
|
||||
or not all(isinstance(p, str) and p for p in meta["phases"])
|
||||
):
|
||||
raise WorkflowInputError("meta.phases must contain non-empty strings")
|
||||
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`:
|
||||
|
||||
| Primitive | Purpose |
|
||||
|------|------|
|
||||
| `agent(prompt, {schema, label, phase})` | Dispatch one subagent |
|
||||
| `parallel(thunks)` | **Barrier**: run every task concurrently and wait until all results return |
|
||||
| `pipeline(items, *stages)` | Run each item through stages **without a barrier**; finished items proceed immediately |
|
||||
| `phase(title)` | Mark the current progress phase and update the progress display |
|
||||
| `log(message)` | Emit a progress log line |
|
||||
| `workflow(name, args)` | Run a nested sub-workflow, one level only |
|
||||
|
||||
`pipeline` should be the default. Each item independently crosses every stage. Item A may reach stage three while item B is still in stage one. Use the `parallel` barrier only when the next stage truly requires every result from the previous stage. A barrier waits for the slowest task, so do not add one without need.
|
||||
|
||||
```python
|
||||
async def pipeline(self, items, *stages):
|
||||
async def run_item(item, idx):
|
||||
value = item
|
||||
for stage in stages: # Each item independently completes every stage
|
||||
value = await stage(value, item, idx)
|
||||
return value
|
||||
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.
|
||||
|
||||
s05 warned that tool arguments cannot be trusted completely. This is the same lesson in reverse: subagent output cannot be trusted completely either. Validate at the orchestration boundary, give one retry, and keep uncertainty out of the rest of the flow.
|
||||
|
||||
```python
|
||||
result = self.runner.run(prompt, schema, label)
|
||||
if schema is not None:
|
||||
ok, err = SimpleJsonSchema(schema).validate(result)
|
||||
if not ok: # Retry once with a reminder, then fail
|
||||
result = self.runner.run(prompt + "\n\nReturn valid JSON.", schema, label)
|
||||
ok, err = SimpleJsonSchema(schema).validate(result)
|
||||
if not ok:
|
||||
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.
|
||||
|
||||
The main session treats these as ordinary events. Only the final completion notification re-enters the main loop.
|
||||
|
||||
```python
|
||||
class LocalWorkflowTask:
|
||||
def progress_event(self, ptype, **data): # Phase/subagent/log
|
||||
self.progress.append({"type": ptype, **data})
|
||||
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 journal is the core of checkpointed resume. It records every `agent()` result one line at a time:
|
||||
|
||||
```python
|
||||
class WorkflowJournal:
|
||||
def record(self, key, value):
|
||||
self._f.write(json.dumps({"key": key, "value": value}) + "\n")
|
||||
self._f.flush()
|
||||
self.cache[key] = value
|
||||
```
|
||||
|
||||
## 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.
|
||||
|
||||
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:
|
||||
|
||||
```python
|
||||
def key(self, kind, label, prompt, schema):
|
||||
basis = f"{kind}|{label}|{prompt}|{json.dumps(schema, sort_keys=True)}"
|
||||
return f"{kind}-{_stable_hash(basis) % 10**10:010d}"
|
||||
|
||||
# Inside agent():
|
||||
cached = self.journal.cached(key)
|
||||
if cached is not MISS:
|
||||
self.task.progress_event("workflow_agent", label=label, status="cached")
|
||||
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.
|
||||
|
||||
## See It Run
|
||||
|
||||
The sample `review-changes` workflow uses `pipeline` to send each review dimension independently through audit → verify. An `agent()` with a schema finds issues during audit. During verification, `parallel()` dispatches a separate adversarial subagent for every finding. Only confirmed issues remain, sorted by severity.
|
||||
|
||||
```python
|
||||
async def sample_workflow(ctx, args):
|
||||
ctx.phase("Review")
|
||||
|
||||
async def audit(_v, dimension, _i):
|
||||
out = await ctx.agent(f"Inspect the changed code for {dimension} issues",
|
||||
schema=FINDINGS_SCHEMA, label=f"audit:{dimension}", phase="Review")
|
||||
return {"dimension": dimension, "findings": out["findings"]}
|
||||
|
||||
async def verify(audited, dimension, _i):
|
||||
ctx.phase("Verify")
|
||||
verdicts = await ctx.parallel([ # Verify every finding independently
|
||||
(lambda f=f: ctx.agent(f"Adversarially verify whether this issue is real: {f['title']}",
|
||||
schema=VERDICT_SCHEMA, label=f"verify:{dimension}:{f['title']}"))
|
||||
for f in audited["findings"]])
|
||||
return {"dimension": dimension,
|
||||
"confirmed": [f for f, v in zip(audited["findings"], verdicts) if v and v["isReal"]]}
|
||||
|
||||
results = await ctx.pipeline(DIMENSIONS, audit, verify)
|
||||
...
|
||||
```
|
||||
|
||||
## Changes from s20
|
||||
|
||||
| | s20 Comprehensive Agent | s21 Workflow Runtime |
|
||||
|--|-----------|---------------------|
|
||||
| Loop | One model-driven loop | Main loop unchanged; deterministic orchestration added above it |
|
||||
| Who decides the next step | Model decides each round | Script declares the orchestration in advance |
|
||||
| Multiple agents | One-shot s06 subagents | Scripted, reproducible, recoverable bulk orchestration |
|
||||
| New mechanisms | — | Script DSL, background tasks, progress events, journal/resume, structured output, deterministic VM |
|
||||
|
||||
s21 does not replace the main loop. It exposes `Workflow` at the tool layer and starts a local workflow runtime behind it: one workflow deterministically drives N agent loops. An s06 subagent is dispatched once at the model's discretion; s21 turns orchestration into a replayable script.
|
||||
|
||||
## Try It
|
||||
|
||||
```bash
|
||||
python s21_workflow_runtime/code.py # Start review-changes and watch the event stream
|
||||
python s21_workflow_runtime/code.py resume # Resume by the last runId; every agent() hits the journal cache
|
||||
```
|
||||
|
||||
Watch one launch produce `async_launched`, followed by background phase changes and subagent progress, then `task_notification`; the result is stored on the task object. A resumed run reports `agents=0 tokens=0` because every call hits the cache, and its result is byte-for-byte identical.
|
||||
|
||||
## Next
|
||||
|
||||
Orchestration adds a layer above agent capabilities: the main loop handles individual operations, while a script manages the whole team's flow. Once work becomes a deterministic, recoverable script, the model changes from the round-by-round driver into an execution unit scheduled by that script. The same `agent()` can be invoked ad hoc by the model in the main loop or orchestrated in bulk inside a workflow.
|
||||
|
||||
Next: [s22 Goal Loop](../s22_goal_loop/) — Orchestration fans work out and leaves the main loop. The next chapter moves in the opposite direction: a goal pulls control back into the main loop and refuses to let the turn end until the objective is achieved.
|
||||
|
||||
<!-- translation-sync: zh@v2, en@v2, ja@v2 -->
|
||||
242
s21_workflow_runtime/README.zh.md
Normal file
242
s21_workflow_runtime/README.zh.md
Normal file
@@ -0,0 +1,242 @@
|
||||
# s21: Workflow Runtime — 模型决定单步,脚本决定编排
|
||||
|
||||
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
|
||||
|
||||
s01 → ... → s19 → s20 → `s21` → [s22](../s22_goal_loop/)
|
||||
|
||||
> *"一次 tool_use,后台跑完一整套编排"* — `Workflow` 工具启动一个确定、可恢复的脚本运行时,批量派出去一堆子 agent。
|
||||
>
|
||||
> **Harness 层**: 编排 — 在单 agent 循环之上,加一层确定的多 agent 脚本运行时。
|
||||
|
||||
> **来源边界:** 本章产品细节来自对 Claude Code 2.1.177 的 clean-room 行为重建。后续版本可能更改名称与限制;`code.py` 是离线教学模型,不是产品源码复制。
|
||||
>
|
||||
> 教学 CLI 会先发出 `async_launched`,随后在同一进程等待完成,以保证输出可复现。它演示的是生命周期与 journal,不是并发运行的主循环。
|
||||
|
||||
---
|
||||
|
||||
从 s01 到 s20,我们的循环一直是模型驱动、一步一步来的:每一轮模型挑一个工具,结果塞回 `messages[]`,再来一轮。开放式任务这么干最合适,下一步做什么,让模型看着上下文临场决定就好。
|
||||
|
||||
但有些活,你需要的是确定地指挥一群 agent 干活。比如审一个大改动:十个维度并行找问题 → 每条发现各自派一个 agent 做对抗性验证 → 结果汇总去重 → 按严重度排序。这种流程的形状是固定的,你要的其实是三样东西:
|
||||
|
||||
- **并行**,别一个一个串着等;
|
||||
- **确定**,同样的输入跑出来同样的结果结构;
|
||||
- **可恢复**,跑到一半断了,已经做完的部分别从头再来。
|
||||
|
||||
让模型在主循环里一步一步驱动这套流程,又慢、结果又不确定,断了还得从头跑。这时候你要的不是"再聊一轮",而是把这套编排直接写成代码。
|
||||
|
||||
## 计划写在代码里,不是靠聊天一轮轮凑
|
||||
|
||||
Claude Code 在工具池里放了一个 `Workflow` 工具。你(或者模型在高强度模式下触发)给它一段脚本,脚本用 `agent() / parallel() / pipeline() / phase()` 这几个简单的原语,把编排写成确定的代码。
|
||||
|
||||
主循环这边只看到一次 `tool_use`,立刻拿到"已在后台启动"的返回:真正的执行在后台运行时里推进,实时上报进度,所有过程都写到磁盘的 journal 文件里。脚本里的中间结果存在变量里,不会塞进对话历史占地方。下次用 `resumeFromRunId` 重启时,没改过的 `agent()` 直接命中 journal 缓存,直接用之前的结果,断点续跑。
|
||||
|
||||

|
||||
|
||||
```python
|
||||
SAMPLE_META = {"name": "review-changes", "description": "审查代码改动", "phases": ["Review", "Verify"]}
|
||||
|
||||
async def sample_workflow(ctx, args):
|
||||
ctx.phase("Review")
|
||||
results = await ctx.pipeline(DIMENSIONS, audit, verify) # 每个维度独立走 审计 → 验证
|
||||
confirmed = [f for r in results if r for f in r["confirmed"]]
|
||||
ctx.log(f"确认了 {len(confirmed)} 个真实问题")
|
||||
return {"confirmed": confirmed}
|
||||
```
|
||||
|
||||
## Workflow 工具:后台启动,主循环只看到一次调用
|
||||
|
||||
`Workflow`(别名 `RunWorkflow`)就在主 agent 的工具池里。触发可能来自你显式说"跑一下这个 workflow"、一个保存好的 `/命令`,或者模型自动进入高强度路径,这时候模型会发一个 `Workflow(...)` 的工具调用。
|
||||
|
||||
工具收到后会解析参数、校验 meta 信息、过权限检查、注册一个本地 workflow 任务,然后立刻返回"已异步启动"。主循环不阻塞,该干嘛干嘛;workflow 自己在后台跑。这其实就是 s13 后台任务那套"凭条模式"的放大版:先给你个取件条,结果好了再通知你。
|
||||
|
||||
```python
|
||||
class WorkflowTool:
|
||||
async def call(self, meta, script_fn, args=None, resume_from_run_id=None):
|
||||
validate_meta(meta)
|
||||
check_permission(meta)
|
||||
run_id = resume_from_run_id or create_run_id(meta)
|
||||
task = LocalWorkflowTask(create_task_id(run_id), run_id, meta)
|
||||
task.event("async_launched", runId=run_id, taskId=task.task_id) # 立刻返回
|
||||
... # 剩下的后台慢慢跑
|
||||
```
|
||||
|
||||
> 真实 Claude Code:工具会立刻返回 `{status:'async_launched', taskId, taskType:'local_workflow', runId, summary, transcriptDir, scriptPath}`,后台任务跑完了再通知。
|
||||
|
||||
## 脚本和 meta:第一行必须写对
|
||||
|
||||
脚本的第一行必须是 `export const meta = { name, description, phases }`,而且必须是纯字面量,不能有变量、函数调用、字符串拼接。运行时在执行任何代码之前先解析它:`name` 和 `description` 用来显示任务和 UI,`phases` 给进度条分组命名。
|
||||
|
||||
不对的输入直接抛 `WorkflowInputError`,注册的时候就拦住——这和 s14 校验 cron 表达式是一个思路:坏脚本别让它跑到执行的时候才炸。
|
||||
|
||||
教学运行时会把 `meta.name` 用在本地产物文件名中,因此还要求它是 1-64 个字符的安全 slug,只能包含字母、数字、`.`、`_`、`-`。
|
||||
|
||||
```python
|
||||
def validate_meta(meta):
|
||||
if not isinstance(meta, dict):
|
||||
raise WorkflowInputError("meta 必须是对象字面量")
|
||||
if not meta.get("name") or not meta.get("description"):
|
||||
raise WorkflowInputError("meta 必须包含 name 和 description")
|
||||
if not isinstance(meta["name"], str) or not WORKFLOW_NAME_RE.fullmatch(meta["name"]):
|
||||
raise WorkflowInputError("meta.name 必须是 1-64 字符的安全 slug")
|
||||
if "phases" in meta and (
|
||||
not isinstance(meta["phases"], list)
|
||||
or not all(isinstance(p, str) and p for p in meta["phases"])
|
||||
):
|
||||
raise WorkflowInputError("meta.phases 必须包含非空字符串")
|
||||
return meta
|
||||
```
|
||||
|
||||
> 真实 Claude Code:`parseWorkflowScript` 强制 meta 必须是第一行且是纯字面量;教学版直接收一个 dict,简化了这部分。
|
||||
|
||||
## 编排原语:就这几个,够写所有流程
|
||||
|
||||
脚本跑在一个独立的上下文里,能用的全局变量就这几个编排原语。脚本本身不直接读写文件、不跑 shell,真正的代码操作都由派出去的子 agent 用它们自己的工具权限完成。这些原语都是 `ExecutionState` 上的方法:
|
||||
|
||||
| 原语 | 作用 |
|
||||
|------|------|
|
||||
| `agent(prompt, {schema, label, phase})` | 派一个子 agent 干活 |
|
||||
| `parallel(thunks)` | **等齐屏障**:所有任务并行跑完,一起等结果回来 |
|
||||
| `pipeline(items, *stages)` | 每个 item 分阶段跑,**不等齐**,跑完一个往下走一个 |
|
||||
| `phase(title)` | 标记当前进度阶段(更新进度条) |
|
||||
| `log(message)` | 打一行进度日志 |
|
||||
| `workflow(name, args)` | 嵌套子工作流(只支持一层) |
|
||||
|
||||
`pipeline` 是你默认该用的:每个 item 独立穿过所有 stage,item A 跑到第 3 阶段的时候,item B 可能还在第 1 阶段;只有真的需要"拿到上一阶段所有结果才能往下走"的时候,才用 `parallel` 这个屏障。屏障的代价是等最慢的那个任务,没必要就别立。
|
||||
|
||||
```python
|
||||
async def pipeline(self, items, *stages):
|
||||
async def run_item(item, idx):
|
||||
value = item
|
||||
for stage in stages: # 每个 item 独立跑完所有 stage
|
||||
value = await stage(value, item, idx)
|
||||
return value
|
||||
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 校验结果,不对就重试一次。这样下游代码拿到的是规整的对象,不是需要再解析的一大段散文。
|
||||
|
||||
s05 就说过,工具的参数不能全信;这里是同一个道理反过来:子 agent 的输出也不能全信。加一层校验,不对就给一次机会重试,把不确定性挡在编排层外面。
|
||||
|
||||
```python
|
||||
result = self.runner.run(prompt, schema, label)
|
||||
if schema is not None:
|
||||
ok, err = SimpleJsonSchema(schema).validate(result)
|
||||
if not ok: # 提醒一次重试,再不对就报错
|
||||
result = self.runner.run(prompt + "\n\n返回合法的 JSON。", schema, label)
|
||||
ok, err = SimpleJsonSchema(schema).validate(result)
|
||||
if not ok:
|
||||
raise WorkflowInputError(f"agent({{schema}}) 输出不合法: {err}")
|
||||
```
|
||||
|
||||
> 真实 Claude Code:用 `SimpleJsonSchema` + `StructuredOutput` 工具 + schema 重试机制保证输出格式。
|
||||
|
||||
## 后台任务和进度事件
|
||||
|
||||
`LocalWorkflowTask` 维护状态和 token 用量,向外发一条 SDK 风格的事件流:`task_started` → 一串 `task_progress`(包含阶段切换、子 agent 启动、日志输出这些批次)→ 最后一个 `task_notification`(完成/失败/停止,带输出文件、token 数、工具调用数、耗时)。
|
||||
|
||||
主会话把这些当普通事件处理;只有最终的完成通知会重新进入主循环。
|
||||
|
||||
```python
|
||||
class LocalWorkflowTask:
|
||||
def progress_event(self, ptype, **data): # 阶段/子agent/日志
|
||||
self.progress.append({"type": ptype, **data})
|
||||
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/`(用户级)。
|
||||
|
||||
journal 是断点续跑的核心,它一条一条记下来每个 `agent()` 的结果:
|
||||
|
||||
```python
|
||||
class WorkflowJournal:
|
||||
def record(self, key, value):
|
||||
self._f.write(json.dumps({"key": key, "value": value}) + "\n")
|
||||
self._f.flush()
|
||||
self.cache[key] = value
|
||||
```
|
||||
|
||||
## resume:用 runId 续跑,没改的直接用缓存
|
||||
|
||||
调用 `Workflow({scriptPath, resumeFromRunId, args})` 会重新跑脚本,但每个 `agent()` 会算一个确定的语义 key:key 在 journal 里有记录,就直接返回缓存的结果(不重跑),没改过的全部命中缓存;只有改过的那个以及它后面的步骤才会真的跑。
|
||||
|
||||
这里有个关键点:key 不能依赖并发顺序。`parallel` 和 `pipeline` 里 agent 完成的顺序是不确定的,用"第几个完成"当 key,两次跑缓存就对错位了。所以 key 是根据调用内容(类型、标签、prompt、schema)算的稳定哈希,不是一个会竞争的计数器:
|
||||
|
||||
```python
|
||||
def key(self, kind, label, prompt, schema):
|
||||
basis = f"{kind}|{label}|{prompt}|{json.dumps(schema, sort_keys=True)}"
|
||||
return f"{kind}-{_stable_hash(basis) % 10**10:010d}"
|
||||
|
||||
# agent() 内部:
|
||||
cached = self.journal.cached(key)
|
||||
if cached is not MISS:
|
||||
self.task.progress_event("workflow_agent", label=label, status="cached")
|
||||
return cached
|
||||
```
|
||||
|
||||
> 真实 Claude Code:同样是"确定语义 key + journal 缓存"的思路;同会话内续跑时,已经完成的 `agent()` 直接返回缓存,后面的才实跑。
|
||||
|
||||
## 确定性:能复现,续跑才有意义
|
||||
|
||||
续跑要能工作,脚本首先得可复现。所以运行时会把 `Date.now()`、无参 `new Date()`、`Math.random()` 这些不确定的东西从脚本上下文里去掉,也不给 Node 原生 API。同一份脚本 + 同样的参数 → 同样的 key → 100% 缓存命中。教学版用稳定哈希算 key 达到同样的效果(真实版是把整段 JS 脚本跑在去掉了这些不确定源的沙箱 VM 里)。
|
||||
|
||||
## 跑起来看看
|
||||
|
||||
示例 workflow `review-changes`:用 `pipeline` 让每个审查维度独立走"审计 → 验证"流程。审计用一个带 schema 的 `agent()` 找问题,验证用 `parallel()` 给每条发现各派一个对抗性验证的子 agent,最后只留确认真实的问题,按严重度排序。
|
||||
|
||||
```python
|
||||
async def sample_workflow(ctx, args):
|
||||
ctx.phase("Review")
|
||||
|
||||
async def audit(_v, dimension, _i):
|
||||
out = await ctx.agent(f"检查改动的代码里有没有{dimension}相关的问题",
|
||||
schema=FINDINGS_SCHEMA, label=f"audit:{dimension}", phase="Review")
|
||||
return {"dimension": dimension, "findings": out["findings"]}
|
||||
|
||||
async def verify(audited, dimension, _i):
|
||||
ctx.phase("Verify")
|
||||
verdicts = await ctx.parallel([ # 每条发现独立做对抗性验证
|
||||
(lambda f=f: ctx.agent(f"请对抗性验证这个问题是不是真的:{f['title']}",
|
||||
schema=VERDICT_SCHEMA, label=f"verify:{dimension}:{f['title']}"))
|
||||
for f in audited["findings"]])
|
||||
return {"dimension": dimension,
|
||||
"confirmed": [f for f, v in zip(audited["findings"], verdicts) if v and v["isReal"]]}
|
||||
|
||||
results = await ctx.pipeline(DIMENSIONS, audit, verify)
|
||||
...
|
||||
```
|
||||
|
||||
## 相对 s20 的变更
|
||||
|
||||
| | s20 综合体 | s21 Workflow Runtime |
|
||||
|--|-----------|---------------------|
|
||||
| 循环 | 单个、模型驱动 | 主循环不变;上面加一层确定的编排 |
|
||||
| 谁决定下一步 | 模型逐轮决定 | 脚本预先写好编排流程 |
|
||||
| 多 agent | s06 子 agent,一次性派出去 | 脚本化、可复现、可恢复的批量编排 |
|
||||
| 新增机制 | — | 脚本 DSL、后台任务、进度事件、journal/续跑、结构化输出、确定性 VM |
|
||||
|
||||
s21 不替换主循环,它只是在工具层暴露了 `Workflow`,背后启动一个本地 workflow 运行时:一个 workflow 确定地驱动 N 个 agent 循环。s06 的子 agent 是模型临场派一次;s21 是把编排写成可以重放的脚本。
|
||||
|
||||
## 试一下
|
||||
|
||||
```bash
|
||||
python s21_workflow_runtime/code.py # 启动 review-changes,看事件流
|
||||
python s21_workflow_runtime/code.py resume # 用上次的 runId 续跑,每个 agent() 都命中 journal 缓存
|
||||
```
|
||||
|
||||
观察:一次启动 → `async_launched` → 后台阶段切换/子agent进度推进 → `task_notification`;结果存在任务对象上。续跑的时候会显示 `agents=0 tokens=0`(全部命中缓存),结果和上次一字不差。
|
||||
|
||||
## 接下来
|
||||
|
||||
编排是在 agent 能力之上又加了一层:主循环管单步操作,脚本管整支队伍的流程。把工作写成确定、可恢复的脚本,模型就从"逐轮驱动者"变成了"被脚本调度的执行单元"。同一个 `agent()`,既能在主循环里被模型临场调用,也能在 workflow 里被脚本批量编排。
|
||||
|
||||
下一章:[s22 Goal Loop](../s22_goal_loop/) — 编排是把工作扇出去、脱离主循环;下一章反过来,一个目标把控制权重拉回主循环,没达成就不让这一轮结束。
|
||||
|
||||
<!-- translation-sync: zh@v2, en@v2, ja@v2 -->
|
||||
566
s21_workflow_runtime/code.py
Normal file
566
s21_workflow_runtime/code.py
Normal file
@@ -0,0 +1,566 @@
|
||||
"""
|
||||
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.
|
||||
|
||||
Idea:
|
||||
s01-s20 build a single, model-driven agent loop. s21 adds a deterministic
|
||||
orchestration LAYER on top: the main loop exposes a `Workflow` tool that
|
||||
launches a background runtime; a script written with agent()/parallel()/
|
||||
pipeline()/phase() drives many subagents deterministically, reports progress,
|
||||
persists a journal, and can resume from a runId.
|
||||
|
||||
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):
|
||||
- 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.
|
||||
- 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/.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# ---- knobs that mirror the real runtime's 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
|
||||
MISS = object() # journal cache miss sentinel
|
||||
WORKFLOW_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
|
||||
RUN_ID_RE = re.compile(r"^wf_[A-Za-z0-9][A-Za-z0-9._-]{0,63}_[0-9]{4}$")
|
||||
|
||||
|
||||
def _stable_hash(s: str) -> int:
|
||||
"""Process-stable hash (Python's hash() is salted per process, which would
|
||||
break resume keys across `run` and `resume`)."""
|
||||
return int(hashlib.sha256(s.encode()).hexdigest(), 16)
|
||||
|
||||
|
||||
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.
|
||||
return f"wf_{meta['name']}_{_stable_hash(meta['name']) % 10000:04d}"
|
||||
|
||||
|
||||
def create_task_id(run_id) -> str:
|
||||
return f"local_workflow_{run_id}"
|
||||
|
||||
|
||||
def validate_run_id(run_id):
|
||||
if not isinstance(run_id, str) or not RUN_ID_RE.fullmatch(run_id):
|
||||
raise WorkflowInputError("invalid workflow runId")
|
||||
return run_id
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Errors
|
||||
# ============================================================
|
||||
class WorkflowInputError(Exception):
|
||||
"""Bad script / meta / schema input (mirrors WorkflowInputError)."""
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 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."""
|
||||
if not isinstance(meta, dict):
|
||||
raise WorkflowInputError("meta must be an object literal")
|
||||
if not meta.get("name") or not meta.get("description"):
|
||||
raise WorkflowInputError("meta requires `name` and `description`")
|
||||
if not isinstance(meta["name"], str) or not WORKFLOW_NAME_RE.fullmatch(meta["name"]):
|
||||
raise WorkflowInputError(
|
||||
"meta.name must be a 1-64 character slug using letters, numbers, '.', '_', or '-'"
|
||||
)
|
||||
if not isinstance(meta["description"], str):
|
||||
raise WorkflowInputError("meta.description must be a string")
|
||||
if "phases" in meta:
|
||||
if not isinstance(meta["phases"], list) or not all(
|
||||
isinstance(phase, str) and phase for phase in meta["phases"]
|
||||
):
|
||||
raise WorkflowInputError("meta.phases must be a list of non-empty strings")
|
||||
return meta
|
||||
|
||||
|
||||
def check_permission(meta, settings=None):
|
||||
"""allow / deny / ask gate before launch (s03 permission system, applied to
|
||||
Workflow). Teaching version allows by default; a deny rule blocks."""
|
||||
settings = settings or {}
|
||||
if meta["name"] in settings.get("deny", []):
|
||||
raise WorkflowInputError(f"workflow '{meta['name']}' denied by settings")
|
||||
return "allow"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Minimal JSON-schema for structured output (SimpleJsonSchema)
|
||||
# ============================================================
|
||||
class SimpleJsonSchema:
|
||||
"""Tiny validator backing agent({schema}). Just enough for teaching:
|
||||
object/array/string/boolean/number + required keys."""
|
||||
|
||||
def __init__(self, schema):
|
||||
self.schema = schema
|
||||
|
||||
def validate(self, value, schema=None):
|
||||
schema = self.schema if schema is None else schema
|
||||
t = schema.get("type")
|
||||
if t == "object":
|
||||
if not isinstance(value, dict):
|
||||
return False, "expected object"
|
||||
for key in schema.get("required", []):
|
||||
if key not in value:
|
||||
return False, f"missing required key '{key}'"
|
||||
for key, sub in schema.get("properties", {}).items():
|
||||
if key in value:
|
||||
ok, err = self.validate(value[key], sub)
|
||||
if not ok:
|
||||
return False, f"{key}: {err}"
|
||||
return True, None
|
||||
if t == "array":
|
||||
if not isinstance(value, list):
|
||||
return False, "expected array"
|
||||
items = schema.get("items")
|
||||
if items:
|
||||
for i, el in enumerate(value):
|
||||
ok, err = self.validate(el, items)
|
||||
if not ok:
|
||||
return False, f"[{i}]: {err}"
|
||||
return True, None
|
||||
if t == "string":
|
||||
return (isinstance(value, str), None if isinstance(value, str) else "expected string")
|
||||
if t == "boolean":
|
||||
return (isinstance(value, bool), None if isinstance(value, bool) else "expected boolean")
|
||||
if t in ("number", "integer"):
|
||||
ok = isinstance(value, (int, float)) and not isinstance(value, bool)
|
||||
return (ok, None if ok else "expected number")
|
||||
return True, None
|
||||
|
||||
|
||||
def _fill_schema(schema, seed):
|
||||
"""Deterministic generic filler used for schemas the mock doesn't special-case."""
|
||||
t = schema.get("type")
|
||||
if t == "object":
|
||||
keys = schema.get("required") or list(schema.get("properties", {}))
|
||||
return {k: _fill_schema(schema["properties"][k], f"{seed}/{k}") for k in keys}
|
||||
if t == "array":
|
||||
return [_fill_schema(schema["items"], f"{seed}/0")]
|
||||
if t == "boolean":
|
||||
return _stable_hash(seed) % 4 != 0
|
||||
if t in ("number", "integer"):
|
||||
return _stable_hash(seed) % 5
|
||||
return seed.rsplit("/", 1)[-1]
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Subagent runner (mock for teaching; real path = an LLM tool loop)
|
||||
# ============================================================
|
||||
class MockAgentRunner:
|
||||
"""Stands in for a spawned subagent. Deterministic so resume is reproducible.
|
||||
A real runner would run an isolated agent loop that calls repo tools and is
|
||||
forced to emit StructuredOutput when a schema is present."""
|
||||
|
||||
def run(self, prompt, schema=None, label=None):
|
||||
if schema is None:
|
||||
return f"[mock] {(label or prompt)[:60]}"
|
||||
props = schema.get("properties", {})
|
||||
if "findings" in props: # an audit agent
|
||||
n = 1 + (_stable_hash(prompt) % 2) # 1-2 findings
|
||||
sev = ["high", "medium", "low"]
|
||||
return {"findings": [
|
||||
{"title": f"{label or 'audit'} #{i + 1}",
|
||||
"severity": sev[_stable_hash(prompt + str(i)) % 3]}
|
||||
for i in range(n)
|
||||
]}
|
||||
if "isReal" in props: # a verifier agent
|
||||
real = _stable_hash(prompt) % 4 != 0 # ~75% confirmed
|
||||
return {"isReal": real,
|
||||
"reason": "reproduced" if real else "could not reproduce"}
|
||||
return _fill_schema(schema, prompt)
|
||||
|
||||
@staticmethod
|
||||
def tokens(prompt, result):
|
||||
return len(prompt) // 4 + len(json.dumps(result, default=str)) // 4
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Journal (resume cache): started/result per agent under a semantic key
|
||||
# ============================================================
|
||||
class WorkflowJournal:
|
||||
"""Append-only <runId>.journal.jsonl. On resume, agent() calls whose
|
||||
semantic key is already present are replayed from cache instead of re-run."""
|
||||
|
||||
def __init__(self, run_id, resume, store=STORE):
|
||||
store.mkdir(parents=True, exist_ok=True)
|
||||
self.path = store / f"{run_id}.journal.jsonl"
|
||||
self.resume = resume
|
||||
self.cache = {}
|
||||
if resume:
|
||||
if not self.path.exists():
|
||||
raise WorkflowInputError(f"resume journal not found for {run_id}")
|
||||
for line_number, line in enumerate(self.path.read_text().splitlines(), start=1):
|
||||
try:
|
||||
rec = json.loads(line)
|
||||
if (
|
||||
not isinstance(rec, dict)
|
||||
or not isinstance(rec.get("key"), str)
|
||||
or "value" not in rec
|
||||
):
|
||||
raise ValueError("expected key/value record")
|
||||
except (json.JSONDecodeError, ValueError) as exc:
|
||||
raise WorkflowInputError(
|
||||
f"invalid resume journal record at line {line_number}"
|
||||
) from exc
|
||||
self.cache[rec["key"]] = rec["value"]
|
||||
self._f = self.path.open("a")
|
||||
else:
|
||||
self._f = self.path.open("w") # fresh run truncates
|
||||
|
||||
def key(self, kind, label, prompt, schema):
|
||||
# Deterministic semantic key — independent of concurrency order, so a
|
||||
# parallel/pipeline call gets the same key on resume.
|
||||
basis = f"{kind}|{label}|{prompt}|{json.dumps(schema, sort_keys=True)}"
|
||||
return f"{kind}-{_stable_hash(basis) % 10**10:010d}"
|
||||
|
||||
def cached(self, key):
|
||||
return self.cache.get(key, MISS)
|
||||
|
||||
def record(self, key, value):
|
||||
self._f.write(json.dumps({"key": key, "value": value}) + "\n")
|
||||
self._f.flush()
|
||||
self.cache[key] = value
|
||||
|
||||
def close(self):
|
||||
self._f.close()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Token budget
|
||||
# ============================================================
|
||||
class Budget:
|
||||
"""budget.total / spent() / remaining(). Once spent reaches total, agent()
|
||||
calls raise (the real runtime enforces the same ceiling)."""
|
||||
|
||||
def __init__(self, total=None):
|
||||
self.total = total
|
||||
self._spent = 0
|
||||
|
||||
def add(self, n):
|
||||
if self.total is not None and self._spent + n > self.total:
|
||||
raise WorkflowInputError(
|
||||
f"token budget exceeded ({self._spent + n} > {self.total})"
|
||||
)
|
||||
self._spent += n
|
||||
|
||||
def spent(self):
|
||||
return self._spent
|
||||
|
||||
def remaining(self):
|
||||
return float("inf") if self.total is None else max(0, self.total - self._spent)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Background task state + progress events (the outer event stream)
|
||||
# ============================================================
|
||||
class LocalWorkflowTask:
|
||||
"""type local_workflow. Holds status/usage and emits the SDK-like event
|
||||
stream: task_started, task_progress (workflow_phase/agent/log), task_notification."""
|
||||
|
||||
def __init__(self, task_id, run_id, meta):
|
||||
self.task_id = task_id
|
||||
self.run_id = run_id
|
||||
self.meta = meta
|
||||
self.status = "running"
|
||||
self.usage = {"agents": 0, "tokens": 0}
|
||||
self.progress = []
|
||||
|
||||
def event(self, name, **data):
|
||||
line = " ".join(f"{k}={v}" for k, v in data.items())
|
||||
print(f" event {name:<18} {line}")
|
||||
|
||||
def progress_event(self, ptype, **data):
|
||||
self.progress.append({"type": ptype, **data})
|
||||
line = " ".join(f"{k}={v}" for k, v in data.items())
|
||||
print(f" progress {ptype:<16} {line}")
|
||||
|
||||
|
||||
# ============================================================
|
||||
# ExecutionState: the DSL the workflow script sees as `ctx`
|
||||
# ============================================================
|
||||
class ExecutionLimits:
|
||||
"""Shared run-wide limits, including nested workflows."""
|
||||
|
||||
def __init__(self):
|
||||
self.agents = 0
|
||||
self.semaphore = asyncio.Semaphore(CONCURRENCY)
|
||||
|
||||
def claim_agent(self):
|
||||
self.agents += 1
|
||||
if self.agents > AGENT_CAP:
|
||||
raise WorkflowInputError(f"agent() cap reached ({AGENT_CAP})")
|
||||
|
||||
|
||||
class ExecutionState:
|
||||
"""Injected into the workflow script. Provides the orchestration primitives.
|
||||
Mirrors ExecutionState in runtime.mjs."""
|
||||
|
||||
def __init__(self, task, journal, runner, budget, args, depth=0, limits=None):
|
||||
self.task = task
|
||||
self.journal = journal
|
||||
self.runner = runner
|
||||
self.budget = budget
|
||||
self.args = args
|
||||
self._depth = depth
|
||||
self._phase = None
|
||||
self._phases_seen = set()
|
||||
self._limits = limits or ExecutionLimits()
|
||||
|
||||
def phase(self, title):
|
||||
"""Start a phase; subsequent agent()s group under it. Upsert: emitting the
|
||||
same phase again (e.g. from each pipeline item) does not re-announce it."""
|
||||
self._phase = title
|
||||
if title not in self._phases_seen:
|
||||
self._phases_seen.add(title)
|
||||
self.task.progress_event("workflow_phase", title=title)
|
||||
|
||||
def log(self, message):
|
||||
"""Emit a workflow_log progress line."""
|
||||
self.task.progress_event("workflow_log", message=message)
|
||||
|
||||
async def agent(self, prompt, schema=None, label=None, phase=None):
|
||||
"""Spawn one subagent. With a schema, force StructuredOutput + validate
|
||||
(retry once). On resume, a cached key short-circuits the run."""
|
||||
label = label or (prompt[:24] + "…")
|
||||
self._limits.claim_agent()
|
||||
if self.budget.remaining() <= 0:
|
||||
raise WorkflowInputError("token budget exceeded")
|
||||
|
||||
key = self.journal.key("agent", label, prompt, schema)
|
||||
cached = self.journal.cached(key)
|
||||
if cached is not MISS:
|
||||
if schema is not None:
|
||||
ok, err = SimpleJsonSchema(schema).validate(cached)
|
||||
if not ok:
|
||||
raise WorkflowInputError(
|
||||
f"cached agent output failed schema validation: {err}"
|
||||
)
|
||||
self.task.progress_event("workflow_agent", label=label,
|
||||
phase=phase or self._phase, status="cached")
|
||||
return cached
|
||||
|
||||
async with self._limits.semaphore:
|
||||
await asyncio.sleep(0) # yield: real subagents are async
|
||||
result = self.runner.run(prompt, schema, label)
|
||||
|
||||
if schema is not None:
|
||||
ok, err = SimpleJsonSchema(schema).validate(result)
|
||||
if not ok: # one nudge/retry, then fail
|
||||
result = self.runner.run(prompt + "\n\nReturn valid JSON.", schema, label)
|
||||
ok, err = SimpleJsonSchema(schema).validate(result)
|
||||
if not ok:
|
||||
raise WorkflowInputError(f"agent({{schema}}) invalid output: {err}")
|
||||
|
||||
toks = self.runner.tokens(prompt, result)
|
||||
self.budget.add(toks)
|
||||
self.task.usage["agents"] += 1
|
||||
self.task.usage["tokens"] += toks
|
||||
self.journal.record(key, result)
|
||||
self.task.progress_event("workflow_agent", label=label,
|
||||
phase=phase or self._phase, status="done")
|
||||
return result
|
||||
|
||||
async def parallel(self, thunks):
|
||||
"""BARRIER: run all thunks concurrently and fail if any thunk fails."""
|
||||
return await asyncio.gather(*[thunk() for thunk in thunks])
|
||||
|
||||
async def pipeline(self, items, *stages):
|
||||
"""Per-item staged flow, NO barrier between stages: item A can be in
|
||||
stage 3 while item B is still in stage 1. Each stage gets
|
||||
(prev_result, original_item, index). A throwing stage fails the workflow."""
|
||||
async def run_item(item, idx):
|
||||
value = item
|
||||
for stage in stages:
|
||||
value = await stage(value, item, idx)
|
||||
return value
|
||||
return await asyncio.gather(*[run_item(it, i) for i, it in enumerate(items)])
|
||||
|
||||
async def workflow(self, name, args=None):
|
||||
"""Run a saved workflow inline as a child (one level), sharing this run's
|
||||
journal + budget + agent counter."""
|
||||
if self._depth >= 1:
|
||||
raise WorkflowInputError("workflow() nesting is one level only")
|
||||
if name not in WORKFLOWS:
|
||||
raise WorkflowInputError(f"unknown workflow '{name}'")
|
||||
meta, fn = WORKFLOWS[name]
|
||||
child = ExecutionState(self.task, self.journal, self.runner, self.budget,
|
||||
args or {}, depth=self._depth + 1,
|
||||
limits=self._limits)
|
||||
return await fn(child, args or {})
|
||||
|
||||
|
||||
# ============================================================
|
||||
# WorkflowTool: the tool entry (WorkflowTool.call)
|
||||
# ============================================================
|
||||
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."""
|
||||
|
||||
async def call(self, meta, script_fn, args=None, resume_from_run_id=None):
|
||||
validate_meta(meta)
|
||||
check_permission(meta)
|
||||
args = args or {}
|
||||
run_id = resume_from_run_id or create_run_id(meta)
|
||||
validate_run_id(run_id)
|
||||
if resume_from_run_id is not None and run_id != create_run_id(meta):
|
||||
raise WorkflowInputError("resume runId does not match workflow meta")
|
||||
task_id = create_task_id(run_id)
|
||||
resuming = resume_from_run_id is not None
|
||||
|
||||
task = LocalWorkflowTask(task_id, run_id, meta)
|
||||
# The real tool returns this immediately and runs the rest in background.
|
||||
launched = {"status": "async_launched", "taskId": task_id,
|
||||
"taskType": "local_workflow", "runId": run_id,
|
||||
"workflowName": meta["name"]}
|
||||
task.event("async_launched", runId=run_id, taskId=task_id)
|
||||
task.event("task_started", workflow=meta["name"],
|
||||
phases=",".join(meta.get("phases", [])) or "-",
|
||||
resume=resuming)
|
||||
|
||||
journal = None
|
||||
try:
|
||||
journal = WorkflowJournal(run_id, resume=resuming)
|
||||
ctx = ExecutionState(
|
||||
task, journal, MockAgentRunner(), Budget(args.get("budget")), args
|
||||
)
|
||||
result = await script_fn(ctx, args)
|
||||
task.status = "completed"
|
||||
except Exception as e: # failed / stopped close the loop too
|
||||
task.status = "failed"
|
||||
result = {"error": str(e)}
|
||||
finally:
|
||||
if journal is not None:
|
||||
journal.close()
|
||||
|
||||
_write_json(STORE / f"{run_id}.output.json", result)
|
||||
_save_last_run(run_id)
|
||||
task.event("task_notification", status=task.status,
|
||||
agents=task.usage["agents"], tokens=task.usage["tokens"],
|
||||
outputFile=f".runtime/{run_id}.output.json")
|
||||
return {"launched": launched, "result": result, "task": task}
|
||||
|
||||
|
||||
def _write_json(path, value):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(value, indent=2, default=str))
|
||||
|
||||
|
||||
def _save_last_run(run_id):
|
||||
(STORE / "last_run.txt").write_text(run_id)
|
||||
|
||||
|
||||
def _read_last_run():
|
||||
p = STORE / "last_run.txt"
|
||||
return p.read_text().strip() if p.exists() else None
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 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"],
|
||||
"properties": {"findings": {"type": "array", "items": {
|
||||
"type": "object", "required": ["title", "severity"],
|
||||
"properties": {"title": {"type": "string"}, "severity": {"type": "string"}}}}},
|
||||
}
|
||||
VERDICT_SCHEMA = {
|
||||
"type": "object", "required": ["isReal", "reason"],
|
||||
"properties": {"isReal": {"type": "boolean"}, "reason": {"type": "string"}},
|
||||
}
|
||||
|
||||
SAMPLE_META = {
|
||||
"name": "review-changes",
|
||||
"description": "Review changed files across dimensions, verify each finding",
|
||||
"phases": ["Review", "Verify"],
|
||||
}
|
||||
|
||||
DIMENSIONS = ["correctness", "security", "performance", "style"]
|
||||
|
||||
|
||||
async def sample_workflow(ctx, args):
|
||||
"""pipeline over review dimensions (audit -> verify-each), then keep only the
|
||||
findings a verifier confirms. The plan is code, not a chat turn."""
|
||||
ctx.phase("Review")
|
||||
|
||||
async def audit(_value, dimension, _idx):
|
||||
out = await ctx.agent(
|
||||
f"Review the changed files for {dimension} issues.",
|
||||
schema=FINDINGS_SCHEMA, label=f"audit:{dimension}", phase="Review")
|
||||
return {"dimension": dimension, "findings": out["findings"]}
|
||||
|
||||
async def verify(audited, dimension, _idx):
|
||||
ctx.phase("Verify")
|
||||
# Each finding is verified by its own adversarial subagent, concurrently.
|
||||
verdicts = await ctx.parallel([
|
||||
(lambda f=f: ctx.agent(
|
||||
f"Adversarially verify this {dimension} finding — is it real? {f['title']}",
|
||||
schema=VERDICT_SCHEMA, label=f"verify:{dimension}:{f['title']}", phase="Verify"))
|
||||
for f in audited["findings"]])
|
||||
confirmed = [f for f, v in zip(audited["findings"], verdicts)
|
||||
if v and v.get("isReal")]
|
||||
return {"dimension": dimension, "confirmed": confirmed}
|
||||
|
||||
results = await ctx.pipeline(DIMENSIONS, audit, verify)
|
||||
confirmed = [{"dimension": r["dimension"], **f}
|
||||
for r in results if r for f in r["confirmed"]]
|
||||
confirmed.sort(key=lambda f: {"high": 0, "medium": 1, "low": 2}.get(f["severity"], 3))
|
||||
ctx.log(f"confirmed {len(confirmed)} real finding(s)")
|
||||
return {"confirmed": confirmed}
|
||||
|
||||
|
||||
# saved workflow registry (.claude/workflows/ analogue)
|
||||
WORKFLOWS = {SAMPLE_META["name"]: (SAMPLE_META, sample_workflow)}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Demo
|
||||
# ============================================================
|
||||
async def main(argv):
|
||||
resume_id = None
|
||||
if argv and argv[0] == "resume":
|
||||
resume_id = _read_last_run()
|
||||
if not resume_id:
|
||||
print("nothing to resume — run `python code.py` first.")
|
||||
return
|
||||
print(f"resuming {resume_id} — unchanged agent() calls hit the journal cache\n")
|
||||
else:
|
||||
print("launching workflow `review-changes`\n")
|
||||
|
||||
tool = WorkflowTool()
|
||||
out = await tool.call(SAMPLE_META, sample_workflow,
|
||||
args={"budget": None}, resume_from_run_id=resume_id)
|
||||
|
||||
print("\nresult:")
|
||||
for f in out["result"].get("confirmed", []):
|
||||
print(f" [{f['severity']:<6}] {f['dimension']}: {f['title']}")
|
||||
t = out["task"]
|
||||
print(f"\nstatus={t.status} agents={t.usage['agents']} tokens={t.usage['tokens']}"
|
||||
f" journal=.runtime/{t.run_id}.journal.jsonl")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main(sys.argv[1:]))
|
||||
120
s21_workflow_runtime/images/workflow-runtime-overview.svg
Normal file
120
s21_workflow_runtime/images/workflow-runtime-overview.svg
Normal file
@@ -0,0 +1,120 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 580" 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="580" rx="8" fill="#ffffff"/>
|
||||
|
||||
<!-- Title -->
|
||||
<text x="480" y="30" text-anchor="middle" fill="#1a1a1a" font-size="19" font-weight="700">Workflow Runtime — one tool_use launches a background orchestration</text>
|
||||
<text x="480" y="50" text-anchor="middle" fill="#888888" font-size="12">the main loop calls Workflow like any tool; a deterministic runtime fans out subagents in the background and can resume</text>
|
||||
|
||||
<!-- ===== Lane 1: Main session loop (canonical agent loop) ===== -->
|
||||
<rect x="20" y="66" width="920" height="160" 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 session loop</text>
|
||||
|
||||
<!-- loop-back over the top: back into messages[] -->
|
||||
<path d="M 828 130 L 828 102 L 99 102 L 99 130" fill="none" stroke="#22c55e" stroke-width="1.5" stroke-dasharray="6,3" marker-end="url(#arrow-green)"/>
|
||||
<text x="463" y="97" text-anchor="middle" fill="#22c55e" font-size="10" font-weight="600">append tool_result / notification -> messages[] (loop continues)</text>
|
||||
<circle cx="828" cy="130" r="3" fill="#22c55e"/>
|
||||
<circle cx="99" cy="130" r="3" fill="#22c55e"/>
|
||||
|
||||
<!-- messages[] -->
|
||||
<rect x="40" y="130" width="118" height="52" rx="6" fill="#ffffff" stroke="#1a1a1a" stroke-width="1.5" stroke-dasharray="6,3"/>
|
||||
<text x="99" y="152" text-anchor="middle" fill="#1a1a1a" font-size="12" font-weight="700" font-family="monospace">messages[]</text>
|
||||
<text x="99" y="169" text-anchor="middle" fill="#888888" font-size="9">message history</text>
|
||||
<line x1="158" y1="156" x2="176" y2="156" stroke="#22c55e" stroke-width="2" marker-end="url(#arrow-green)"/>
|
||||
|
||||
<!-- LLM -->
|
||||
<rect x="178" y="130" width="86" height="52" rx="6" fill="#ffffff" stroke="#1a1a1a" stroke-width="1.5"/>
|
||||
<text x="221" y="152" text-anchor="middle" fill="#1a1a1a" font-size="13" font-weight="700">LLM</text>
|
||||
<text x="221" y="169" text-anchor="middle" fill="#888888" font-size="9">tool_use?</text>
|
||||
<line x1="264" y1="156" x2="282" y2="156" stroke="#22c55e" stroke-width="2" marker-end="url(#arrow-green)"/>
|
||||
|
||||
<!-- 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>
|
||||
<line x1="480" y1="156" x2="518" y2="156" stroke="#22c55e" stroke-width="2" marker-end="url(#arrow-green)"/>
|
||||
|
||||
<!-- tool_result -->
|
||||
<rect x="520" y="130" width="156" height="52" rx="6" fill="#fafafa" stroke="#d0d0d0" stroke-width="1.5"/>
|
||||
<text x="598" y="151" text-anchor="middle" fill="#1a1a1a" font-size="12" font-weight="700">tool_result</text>
|
||||
<text x="598" y="169" text-anchor="middle" fill="#888888" font-size="9" font-family="monospace">async_launched</text>
|
||||
|
||||
<!-- later (dashed gap) -->
|
||||
<line x1="676" y1="156" x2="734" y2="156" stroke="#888888" stroke-width="1.5" stroke-dasharray="5,3" marker-end="url(#arrow-gray)"/>
|
||||
<text x="705" y="148" text-anchor="middle" fill="#888888" font-size="8">later</text>
|
||||
|
||||
<!-- task_notification -->
|
||||
<rect x="738" y="130" width="180" height="52" rx="6" fill="#fafafa" stroke="#d0d0d0" stroke-width="1.5" stroke-dasharray="6,3"/>
|
||||
<text x="828" y="151" text-anchor="middle" fill="#1a1a1a" font-size="12" font-weight="700">task_notification</text>
|
||||
<text x="828" y="169" text-anchor="middle" fill="#888888" font-size="9">completed · final report</text>
|
||||
|
||||
<!-- ===== Lane 2: Background workflow runtime ===== -->
|
||||
<rect x="20" y="270" width="920" height="244" rx="8" fill="#ffffff" stroke="#d0d0d0" stroke-width="1.5" stroke-dasharray="6,3"/>
|
||||
<text x="40" y="290" fill="#1a1a1a" font-size="13" font-weight="700">Background workflow runtime — local_workflow</text>
|
||||
|
||||
<!-- spine: WorkflowTool.call -> LocalWorkflowTask -> Script VM -->
|
||||
<rect x="44" y="316" width="158" height="66" rx="6" fill="#ffffff" stroke="#1a1a1a" stroke-width="1.5"/>
|
||||
<text x="123" y="338" text-anchor="middle" fill="#1a1a1a" font-size="11" font-weight="700" font-family="monospace">WorkflowTool.call</text>
|
||||
<text x="123" y="355" text-anchor="middle" fill="#888888" font-size="9">validate meta · permission</text>
|
||||
<text x="123" y="370" text-anchor="middle" fill="#888888" font-size="9" font-family="monospace">runId · taskId</text>
|
||||
<line x1="202" y1="349" x2="220" y2="349" stroke="#22c55e" stroke-width="2" marker-end="url(#arrow-green)"/>
|
||||
|
||||
<rect x="222" y="316" width="158" height="66" rx="6" fill="#fafafa" stroke="#d0d0d0" stroke-width="1.5"/>
|
||||
<text x="301" y="338" text-anchor="middle" fill="#1a1a1a" font-size="11" font-weight="700">LocalWorkflowTask</text>
|
||||
<text x="301" y="355" text-anchor="middle" fill="#888888" font-size="9">status · usage</text>
|
||||
<text x="301" y="370" text-anchor="middle" fill="#888888" font-size="9">progress events</text>
|
||||
<line x1="380" y1="349" x2="398" y2="349" stroke="#22c55e" stroke-width="2" marker-end="url(#arrow-green)"/>
|
||||
|
||||
<rect x="400" y="316" width="200" height="66" rx="6" fill="#ffffff" stroke="#1a1a1a" stroke-width="1.5" stroke-dasharray="6,3"/>
|
||||
<text x="500" y="338" text-anchor="middle" fill="#1a1a1a" font-size="12" font-weight="700">Script VM — runs the script</text>
|
||||
<text x="500" y="356" text-anchor="middle" fill="#888888" font-size="9.5" font-family="monospace">phase · agent()</text>
|
||||
<text x="500" y="371" text-anchor="middle" fill="#888888" font-size="9.5" font-family="monospace">parallel · pipeline</text>
|
||||
|
||||
<!-- agent() cycle: VM -> Subagents -> Journal -> (resume) -> VM -->
|
||||
<!-- Subagents (× N, fan-out: stacked) -->
|
||||
<rect x="666" y="308" width="170" height="58" rx="6" fill="#f3f4f6" stroke="#d0d0d0" stroke-width="1.2"/>
|
||||
<rect x="662" y="312" width="170" height="58" rx="6" fill="#ffffff" stroke="#1a1a1a" stroke-width="1.5"/>
|
||||
<text x="747" y="334" text-anchor="middle" fill="#1a1a1a" font-size="11" font-weight="700">Subagents × N</text>
|
||||
<text x="747" y="350" text-anchor="middle" fill="#888888" font-size="9">isolated ctx</text>
|
||||
<text x="747" y="363" text-anchor="middle" fill="#888888" font-size="9">schema output</text>
|
||||
|
||||
<!-- Journal -->
|
||||
<rect x="662" y="420" width="170" height="58" rx="6" fill="#fafafa" stroke="#d0d0d0" stroke-width="1.5"/>
|
||||
<text x="747" y="442" text-anchor="middle" fill="#1a1a1a" font-size="11" font-weight="700">Journal</text>
|
||||
<text x="747" y="458" text-anchor="middle" fill="#888888" font-size="9" font-family="monospace">started / result</text>
|
||||
<text x="747" y="471" text-anchor="middle" fill="#888888" font-size="9" font-family="monospace">per agent()</text>
|
||||
|
||||
<!-- VM -> Subagents: agent() spawns -->
|
||||
<line x1="600" y1="341" x2="660" y2="341" stroke="#22c55e" stroke-width="2" marker-end="url(#arrow-green)"/>
|
||||
<text x="630" y="333" text-anchor="middle" fill="#22c55e" font-size="9" font-weight="600">agent()</text>
|
||||
<text x="630" y="356" text-anchor="middle" fill="#888888" font-size="8">spawns</text>
|
||||
|
||||
<!-- Subagents -> Journal: record -->
|
||||
<line x1="747" y1="370" x2="747" y2="418" stroke="#22c55e" stroke-width="2" marker-end="url(#arrow-green)"/>
|
||||
<text x="757" y="398" fill="#22c55e" font-size="9" font-weight="600">record</text>
|
||||
|
||||
<!-- 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 -> cached agent()</text>
|
||||
|
||||
<!-- ===== Cross arrows between lanes ===== -->
|
||||
<!-- launch (outer Workflow tool_use -> inner runtime), left side -->
|
||||
<path d="M 382 182 L 382 248 L 123 248 L 123 316" fill="none" stroke="#22c55e" stroke-width="1.5" stroke-dasharray="6,3" marker-end="url(#arrow-green)"/>
|
||||
<text x="152" y="242" fill="#22c55e" font-size="9" font-weight="600">launch (async)</text>
|
||||
|
||||
<!-- progress / notification (inner task -> outer notification), routed just below launch -->
|
||||
<path d="M 301 316 L 301 256 L 828 256 L 828 182" fill="none" stroke="#888888" stroke-width="1.5" stroke-dasharray="5,3" marker-end="url(#arrow-gray)"/>
|
||||
<text x="600" y="250" text-anchor="middle" fill="#888888" font-size="9" font-weight="600">task_progress: workflow_phase · workflow_agent · workflow_log</text>
|
||||
|
||||
<!-- ===== Bottom note ===== -->
|
||||
<text x="480" y="548" text-anchor="middle" fill="#888888" font-size="10">The runtime result stays on the task (scriptPath · transcripts · journal · output) — only the launch + final notification re-enter messages[].</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 9.1 KiB |
Reference in New Issue
Block a user