Files
analysis_claude_code/s21_workflow_runtime/README.ja.md

229 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 を追加します。
`code.py` は demo を決定的に保つため、`async_launched` を出した後、同じ process で完了を待ちます。常駐 background service を用意しなくても、lifecycle と journal を確認できます。
---
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 のラウンドを重ねず、コードに書く
harness の tool pool に `Workflow` ツールを追加します。ユーザーまたはモデルが渡す script は、`agent() / parallel() / pipeline() / phase()` という少数の primitive を使い、orchestration を決定的なコードとして表します。
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)
```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` は 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 を拡大したものです。先に引換券を渡し、結果ができたら通知します。
```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
... # 残りはバックグラウンドで進む
```
## Workflow metadata: 起動前に検証する
各 workflow は `name``description`、任意の `phases` を持つ metadata object を登録します。runtime は workflow code を実行する前に検証します。`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
```
## 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-workflow1 階層だけ) |
既定では `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)])
```
## 構造化出力: 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}")
```
## 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} ...")
```
## 保存: Snapshot + journal で中断から再開する
この最小 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 行ずつ記録します。
```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 から続行し、変更のないものを再利用する
`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 です。
```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
```
## 決定性: Resume に意味を持たせる再現性
resume が動くには、workflow が再現可能でなければなりません。この最小 Python runtime は stable hash と決定的な mock runner を使い、同じ workflow + 同じ argument から同じ key を作ります。production harness では workflow code も隔離し、制御されていない clock、randomness、filesystem access などの非決定的な source を除くべきです。
## 実際に動かす
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 -->