mirror of
https://github.com/shareAI-lab/analysis_claude_code.git
synced 2026-09-20 12:13:38 +08:00
feat: consolidate course into 21 lessons
This commit is contained in:
228
s20_workflow_runtime/README.ja.md
Normal file
228
s20_workflow_runtime/README.ja.md
Normal file
@@ -0,0 +1,228 @@
|
||||
# s20: Workflow Runtime — モデルが単一 step を決め、script が orchestration を決める
|
||||
|
||||
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
|
||||
|
||||
s01 → ... → s18 → s19 → `s20` → [s21](../s21_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 から s19 まで、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 から続行します。
|
||||
|
||||

|
||||
|
||||
```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-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)])
|
||||
```
|
||||
|
||||
## 構造化出力: 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 を `s20_workflow_runtime/.runtime/` に保存します。`<runId>.json` snapshot、`<runId>.output.json` output、`<runId>.journal.jsonl` journal です。snapshot と journal は安定した `runId` を共有し、resume 時に同じ run の状態と完了済み step を特定できるようにします。
|
||||
|
||||
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 が再現可能でなければなりません。stable hash と決定的な runner は、同じ workflow + 同じ argument から同じ key を作ります。そのため workflow code は、制御されていない clock、randomness、filesystem state など、run ごとに key を変える入力を避けます。
|
||||
|
||||
## 実際に動かす
|
||||
|
||||
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)
|
||||
...
|
||||
```
|
||||
|
||||
## s19 からの変更点
|
||||
|
||||
| | s19 Comprehensive Agent | s20 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 |
|
||||
|
||||
s20 は main loop を置き換えません。tool layer に `Workflow` を公開し、背後で local workflow runtime を起動します。1 つの workflow が N 個の Agent loop を決定的に駆動します。s06 の subagent はモデルがその場で 1 回派遣し、s20 は orchestration を replay 可能な script にします。
|
||||
|
||||
## 試してみる
|
||||
|
||||
```bash
|
||||
python s20_workflow_runtime/code.py # review-changes を起動し、event stream を確認
|
||||
python s20_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 がまとめて編成することもできます。
|
||||
|
||||
次へ: [s21 Goal Loop](../s21_goal_loop/) — Orchestration は仕事を fan-out し、main loop から離れます。次章は逆に、1 つの goal が control を main loop へ引き戻し、objective が達成されるまで turn の終了を認めません。
|
||||
|
||||
<!-- translation-sync: zh@v2, en@v2, ja@v2 -->
|
||||
228
s20_workflow_runtime/README.md
Normal file
228
s20_workflow_runtime/README.md
Normal file
@@ -0,0 +1,228 @@
|
||||
# s20: Workflow Runtime — The Model Decides Each Step; a Script Decides the Orchestration
|
||||
|
||||
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
|
||||
|
||||
s01 → ... → s18 → s19 → `s20` → [s21](../s21_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.
|
||||
|
||||
`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.
|
||||
|
||||
---
|
||||
|
||||
From s01 through s19, 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
|
||||
|
||||
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 `resume_from_run_id`, 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` 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.
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
## Workflow Metadata: Validate Before Launch
|
||||
|
||||
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.
|
||||
|
||||
Because the 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
|
||||
```
|
||||
|
||||
## 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)])
|
||||
```
|
||||
|
||||
## 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}")
|
||||
```
|
||||
|
||||
## 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} ...")
|
||||
```
|
||||
|
||||
## Storage: Snapshot + Journal for Resuming after Interruptions
|
||||
|
||||
The runtime stores each run under `s20_workflow_runtime/.runtime/`: a `<runId>.json` snapshot, `<runId>.output.json` output, and `<runId>.journal.jsonl` journal. The snapshot and journal share a stable `runId`, so resume can locate one run's state and completed steps.
|
||||
|
||||
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 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:
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
## Determinism: Reproducibility Makes Resume Meaningful
|
||||
|
||||
Resume works only if the workflow is reproducible. Stable hashes and a deterministic runner make the same workflow plus the same arguments produce the same keys. Workflow code must therefore avoid uncontrolled clocks, randomness, filesystem state, and other inputs that would change those keys between runs.
|
||||
|
||||
## 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 s19
|
||||
|
||||
| | s19 Comprehensive Agent | s20 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 |
|
||||
|
||||
s20 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; s20 turns orchestration into a replayable script.
|
||||
|
||||
## Try It
|
||||
|
||||
```bash
|
||||
python s20_workflow_runtime/code.py # Start review-changes and watch the event stream
|
||||
python s20_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: [s21 Goal Loop](../s21_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 -->
|
||||
228
s20_workflow_runtime/README.zh.md
Normal file
228
s20_workflow_runtime/README.zh.md
Normal file
@@ -0,0 +1,228 @@
|
||||
# s20: Workflow Runtime — 模型决定单步,脚本决定编排
|
||||
|
||||
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
|
||||
|
||||
s01 → ... → s18 → s19 → `s20` → [s21](../s21_goal_loop/)
|
||||
|
||||
> *"一次 tool_use,后台跑完一整套编排"* — `Workflow` 工具启动一个确定、可恢复的脚本运行时,批量派出去一堆子 agent。
|
||||
>
|
||||
> **Harness 层**: 编排 — 在单 agent 循环之上,加一层确定的多 agent 脚本运行时。
|
||||
|
||||
`code.py` 为了让演示保持确定,会先发出 `async_launched`,随后在同一进程里等待执行完成。这样不用启动常驻后台服务,也能看清生命周期和 journal。
|
||||
|
||||
---
|
||||
|
||||
从 s01 到 s19,我们的循环一直是模型驱动、一步一步来的:每一轮模型挑一个工具,结果塞回 `messages[]`,再来一轮。开放式任务这么干最合适,下一步做什么,让模型看着上下文临场决定就好。
|
||||
|
||||
但有些活,你需要的是确定地指挥一群 agent 干活。比如审一个大改动:十个维度并行找问题 → 每条发现各自派一个 agent 做对抗性验证 → 结果汇总去重 → 按严重度排序。这种流程的形状是固定的,你要的其实是三样东西:
|
||||
|
||||
- **并行**,别一个一个串着等;
|
||||
- **确定**,同样的输入跑出来同样的结果结构;
|
||||
- **可恢复**,跑到一半断了,已经做完的部分别从头再来。
|
||||
|
||||
让模型在主循环里一步一步驱动这套流程,又慢、结果又不确定,断了还得从头跑。这时候你要的不是"再聊一轮",而是把这套编排直接写成代码。
|
||||
|
||||
## 计划写在代码里,不是靠聊天一轮轮凑
|
||||
|
||||
在 harness 的工具池里加入一个 `Workflow` 工具。用户或模型给它一段脚本,脚本用 `agent() / parallel() / pipeline() / phase()` 这几个简单的原语,把编排写成确定的代码。
|
||||
|
||||
主循环这边只看到一次 `tool_use`,立刻拿到"已在后台启动"的返回:真正的执行在后台运行时里推进,实时上报进度,所有过程都写到磁盘的 journal 文件里。脚本里的中间结果存在变量里,不会塞进对话历史占地方。下次用 `resume_from_run_id` 重启时,没改过的 `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` 就在主 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) # 立刻返回
|
||||
... # 剩下的后台慢慢跑
|
||||
```
|
||||
|
||||
## Workflow 元数据:启动前先校验
|
||||
|
||||
每个 workflow 都要注册一个元数据对象,包含 `name`、`description` 和可选的 `phases`。运行时会在执行任何 workflow 代码之前校验它:`name` 和 `description` 用来标识任务,`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
|
||||
```
|
||||
|
||||
## 编排原语:就这几个,够写所有流程
|
||||
|
||||
脚本跑在一个独立的上下文里,能用的全局变量就这几个编排原语。脚本本身不直接读写文件、不跑 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)])
|
||||
```
|
||||
|
||||
## 结构化输出:别让子 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}")
|
||||
```
|
||||
|
||||
## 后台任务和进度事件
|
||||
|
||||
`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} ...")
|
||||
```
|
||||
|
||||
## 存储:快照 + journal,断了能续
|
||||
|
||||
运行时把每次运行的数据存在 `s20_workflow_runtime/.runtime/`:快照 `<runId>.json`、输出 `<runId>.output.json` 和 journal `<runId>.journal.jsonl`。快照与 journal 共享稳定的 `runId`,续跑时才能找到同一次运行的状态和已完成步骤。
|
||||
|
||||
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 续跑,没改的直接用缓存
|
||||
|
||||
带着 `resume_from_run_id` 再次调用 workflow 时,脚本会重新执行,但每个 `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
|
||||
```
|
||||
|
||||
## 确定性:能复现,续跑才有意义
|
||||
|
||||
续跑要能工作,workflow 首先得可复现。稳定哈希和确定性的 runner 让同一份 workflow + 同样的参数产生同样的 key。因此 workflow 代码要避免不受控的时钟、随机数、文件系统状态等会让 key 在两次运行间变化的输入。
|
||||
|
||||
## 跑起来看看
|
||||
|
||||
示例 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)
|
||||
...
|
||||
```
|
||||
|
||||
## 相对 s19 的变更
|
||||
|
||||
| | s19 综合体 | s20 Workflow Runtime |
|
||||
|--|-----------|---------------------|
|
||||
| 循环 | 单个、模型驱动 | 主循环不变;上面加一层确定的编排 |
|
||||
| 谁决定下一步 | 模型逐轮决定 | 脚本预先写好编排流程 |
|
||||
| 多 agent | s06 子 agent,一次性派出去 | 脚本化、可复现、可恢复的批量编排 |
|
||||
| 新增机制 | — | 脚本 DSL、后台任务、进度事件、journal/续跑、结构化输出、确定性 VM |
|
||||
|
||||
s20 不替换主循环,它只是在工具层暴露了 `Workflow`,背后启动一个本地 workflow 运行时:一个 workflow 确定地驱动 N 个 agent 循环。s06 的子 agent 是模型临场派一次;s20 是把编排写成可以重放的脚本。
|
||||
|
||||
## 试一下
|
||||
|
||||
```bash
|
||||
python s20_workflow_runtime/code.py # 启动 review-changes,看事件流
|
||||
python s20_workflow_runtime/code.py resume # 用上次的 runId 续跑,每个 agent() 都命中 journal 缓存
|
||||
```
|
||||
|
||||
观察:一次启动 → `async_launched` → 后台阶段切换/子agent进度推进 → `task_notification`;结果存在任务对象上。续跑的时候会显示 `agents=0 tokens=0`(全部命中缓存),结果和上次一字不差。
|
||||
|
||||
## 接下来
|
||||
|
||||
编排是在 agent 能力之上又加了一层:主循环管单步操作,脚本管整支队伍的流程。把工作写成确定、可恢复的脚本,模型就从"逐轮驱动者"变成了"被脚本调度的执行单元"。同一个 `agent()`,既能在主循环里被模型临场调用,也能在 workflow 里被脚本批量编排。
|
||||
|
||||
下一章:[s21 Goal Loop](../s21_goal_loop/) — 编排是把工作扇出去、脱离主循环;下一章反过来,一个目标把控制权重拉回主循环,没达成就不让这一轮结束。
|
||||
|
||||
<!-- translation-sync: zh@v2, en@v2, ja@v2 -->
|
||||
552
s20_workflow_runtime/code.py
Normal file
552
s20_workflow_runtime/code.py
Normal file
@@ -0,0 +1,552 @@
|
||||
"""
|
||||
s20_workflow_runtime — minimal dynamic Workflow runtime
|
||||
|
||||
Idea:
|
||||
s01-s19 build a single, model-driven agent loop. s20 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
|
||||
|
||||
Implementation choices:
|
||||
- MockAgentRunner is deterministic so resume behavior is reproducible.
|
||||
- A workflow is a plain async Python function.
|
||||
- The CLI emits `async_launched` and then awaits completion so event order is
|
||||
deterministic.
|
||||
- Storage is a local .runtime/ directory beside this file.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# ---- 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
|
||||
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:
|
||||
# Keep the ID deterministic so `resume` lands on the same journal file.
|
||||
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 workflow, metadata, or schema input."""
|
||||
|
||||
|
||||
# ============================================================
|
||||
# meta validation
|
||||
# ============================================================
|
||||
def validate_meta(meta):
|
||||
"""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"):
|
||||
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):
|
||||
"""Apply the s03 allow/deny gate before launching a workflow."""
|
||||
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}):
|
||||
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]
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Deterministic subagent runner
|
||||
# ============================================================
|
||||
class MockAgentRunner:
|
||||
"""Runs deterministic subagent outputs so resume is reproducible."""
|
||||
|
||||
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 instead of silently overspending."""
|
||||
|
||||
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 with the orchestration primitives."""
|
||||
|
||||
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 lifecycle
|
||||
events while the CLI awaits the final result. Supports resume."""
|
||||
|
||||
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)
|
||||
# Record the launch envelope before workflow execution starts.
|
||||
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.
|
||||
# ============================================================
|
||||
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
|
||||
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
s20_workflow_runtime/images/workflow-runtime-overview.svg
Normal file
120
s20_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 | 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 -->
|
||||
<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">resume_from_run_id -> 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