Consolidate agent harness course into 19 lessons

This commit is contained in:
Haoran
2026-08-04 02:25:40 +08:00
parent 2ad77cee19
commit b36dbcd84f
168 changed files with 6544 additions and 10400 deletions

View File

@@ -0,0 +1,229 @@
# s18: Workflow Runtime — モデルが単一 step を決め、script が orchestration を決める
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → ... → s16 → [s17](../s17_integrated_harness/) → `s18` → [s19](../s19_goal_loop/)
> *「1 回の tool_use で、一式の orchestration を実行する」* — `Workflow` ツールが決定的で復元可能な script runtime を起動し、多数の subagent をまとめて送り出します。
>
> **Harness 層**: Orchestration — single-agent loop の上に、決定的な multi-agent script runtime を追加します。
---
s01 から s17 まで、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` だけです。script の実行中、runtime は lifecycle event と progress event を出し、各 step をディスク上の journal へ記録します。script が終わると、この call は launch 情報、result、task state を返します。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 ツール: 1 回の call で run 全体を実行する
`Workflow` は main Agent の tool pool にあります。ユーザーが保存済み workflow の実行を求めるか、タスクが既知の orchestration に一致したときにモデルがこのツールを選びます。どちらも 1 回の `Workflow(...)` tool call になります。
ツールは argument を parse し、meta 情報を検証し、permission check を通し、local workflow task を登録して、script の実行前に `async_launched` を出します。その後に progress event と最後の `task_notification` が続き、call は launch 情報、result、task state を返します。
```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)
...
result = await script_fn(ctx, args)
task.event("task_notification", status=task.status)
return {"launched": launched, "result": result, "task": task}
```
## 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}")
```
## Task state と progress event
`LocalWorkflowTask` は status と token usage を管理し、SDK style の event stream を外へ出します。`task_started` → phase change、subagent start、log を含む一連の `task_progress` → 完了または失敗に加え、output file、agent 数、token 数を含む最後の `task_notification` です。
demo はこれらの event を順番に表示し、最後の notification の後で task state を返します。
```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 を `s18_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)
...
```
## s17 からの変更点
| | s17 Integrated Harness | s18 Workflow Runtime |
|--|-----------|---------------------|
| loop | 1 つ、モデル駆動 | main loop は不変。その上に決定的 orchestration を追加 |
| 次の step を決めるもの | モデルが毎ラウンド判断 | script が orchestration flow を事前に定義 |
| multi-agent | s06 subagent を一度だけ派遣 | script 化された、再現可能で復元可能な一括 orchestration |
| 新しい仕組み | — | script DSL、task lifecycle、progress event、journal/resume、structured output、deterministic VM |
s18 は main loop を置き換えません。tool layer に `Workflow` を公開し、背後で local workflow runtime を起動します。1 つの workflow が N 個の Agent loop を決定的に駆動します。s06 の subagent はモデルがその場で 1 回派遣し、s18 は orchestration を replay 可能な script にします。
## 試してみる
```bash
python s18_workflow_runtime/code.py # review-changes を起動し、event stream を確認
python s18_workflow_runtime/code.py resume # 前回の runId から resume。すべての agent() が journal cache に当たる
```
1 回の起動から `async_launched`、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 がまとめて編成することもできます。
次へ: [s19 Goal Loop](../s19_goal_loop/) — Orchestration は仕事を複数の agent へ fan-out します。次章は逆に、1 つの goal が control を main loop へ引き戻し、objective が達成されるまで turn の終了を認めません。
<!-- translation-sync: zh@v2, en@v2, ja@v2 -->

View File

@@ -0,0 +1,229 @@
# s18: Workflow Runtime — The Model Decides Each Step; a Script Decides the Orchestration
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → ... → s16 → [s17](../s17_integrated_harness/) → `s18` → [s19](../s19_goal_loop/)
> *"One tool_use runs an entire orchestration"* — 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.
---
From s01 through s17, 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`. As the script runs, the runtime emits lifecycle and progress events and records every step in a journal on disk. When the script finishes, the call returns the launch envelope, result, and task state. Intermediate script results live in variables instead of taking space in conversation history. When restarted with `resume_from_run_id`, unchanged `agent()` calls hit the journal cache and reuse previous results, resuming from the checkpoint.
![Workflow Runtime Overview](images/workflow-runtime-overview.svg)
```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: One Call, One Complete Run
`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 emits `async_launched` before running the script. Progress events follow, then the final `task_notification`; the call returns the launch envelope, result, and task state.
```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)
...
result = await script_fn(ctx, args)
task.event("task_notification", status=task.status)
return {"launched": launched, "result": result, "task": task}
```
## 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}")
```
## Task State 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 or failure, plus the output file and agent and token counts.
The demo prints these events in order and returns the task state after the final notification.
```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 `s18_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 s17
| | s17 Integrated Harness | s18 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, task lifecycle, progress events, journal/resume, structured output, deterministic VM |
s18 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; s18 turns orchestration into a replayable script.
## Try It
```bash
python s18_workflow_runtime/code.py # Start review-changes and watch the event stream
python s18_workflow_runtime/code.py resume # Resume by the last runId; every agent() hits the journal cache
```
Watch one launch produce `async_launched`, followed by 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: [s19 Goal Loop](../s19_goal_loop/) — Orchestration fans work out across agents. 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 -->

View File

@@ -0,0 +1,229 @@
# s18: Workflow Runtime — 模型决定单步,脚本决定编排
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → ... → s16 → [s17](../s17_integrated_harness/) → `s18` → [s19](../s19_goal_loop/)
> *"一次 tool_use跑完一整套编排"* — `Workflow` 工具启动一个确定、可恢复的脚本运行时,批量派出去一堆子 agent。
>
> **Harness 层**: 编排 — 在单 agent 循环之上,加一层确定的多 agent 脚本运行时。
---
从 s01 到 s17我们的循环一直是模型驱动、一步一步来的每一轮模型挑一个工具结果塞回 `messages[]`,再来一轮。开放式任务这么干最合适,下一步做什么,让模型看着上下文临场决定就好。
但有些活,你需要的是确定地指挥一群 agent 干活。比如审一个大改动:十个维度并行找问题 → 每条发现各自派一个 agent 做对抗性验证 → 结果汇总去重 → 按严重度排序。这种流程的形状是固定的,你要的其实是三样东西:
- **并行**,别一个一个串着等;
- **确定**,同样的输入跑出来同样的结果结构;
- **可恢复**,跑到一半断了,已经做完的部分别从头再来。
让模型在主循环里一步一步驱动这套流程,会拖慢执行速度、增加结果的不确定性,中断后还得从头运行。更合适的做法是把整套编排直接写成代码。
## 计划写在代码里,不是靠聊天一轮轮凑
在 harness 的工具池里加入一个 `Workflow` 工具。用户或模型给它一段脚本,脚本用 `agent() / parallel() / pipeline() / phase()` 这几个简单的原语,把编排写成确定的代码。
主循环这边只看到一次 `tool_use`。脚本运行时runtime 会不断发出生命周期和进度事件,并把每一步写进磁盘上的 journal。脚本结束后这次调用返回启动信息、结果和任务状态。脚本里的中间结果存在变量里不会塞进对话历史占地方。下次用 `resume_from_run_id` 重启时,没改过的 `agent()` 直接命中 journal 缓存,直接用之前的结果,断点续跑。
![Workflow Runtime 总览](images/workflow-runtime-overview.svg)
```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 任务,并在执行脚本前发出 `async_launched`。接下来依次发出进度事件和最终的 `task_notification`;调用返回启动信息、结果和任务状态。
```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)
...
result = await script_fn(ctx, args)
task.event("task_notification", status=task.status)
return {"launched": launched, "result": result, "task": task}
```
## 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 独立穿过所有 stageitem 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`完成或失败带输出文件、agent 数和 token 数)。
演示会按顺序打印这些事件,并在最终通知后返回任务状态。
```python
class LocalWorkflowTask:
def progress_event(self, ptype, **data): # 阶段/子agent/日志
self.progress.append({"type": ptype, **data})
print(f" 进度 {ptype} ...")
```
## 存储:快照 + journal断了能续
运行时把每次运行的数据存在 `s18_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()` 都会计算一个确定的语义 keykey 在 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)
...
```
## 相对 s17 的变更
| | s17 Agent Harness 集成 | s18 Workflow Runtime |
|--|-----------|---------------------|
| 循环 | 单个、模型驱动 | 主循环不变;上面加一层确定的编排 |
| 谁决定下一步 | 模型逐轮决定 | 脚本预先写好编排流程 |
| 多 agent | s06 子 agent一次性派出去 | 脚本化、可复现、可恢复的批量编排 |
| 新增机制 | — | 脚本 DSL、任务生命周期、进度事件、journal/续跑、结构化输出、确定性 VM |
s18 不替换主循环,它只是在工具层暴露了 `Workflow`,背后启动一个本地 workflow 运行时:一个 workflow 确定地驱动 N 个 agent 循环。s06 的子 agent 是模型临场派一次s18 是把编排写成可以重放的脚本。
## 试一下
```bash
python s18_workflow_runtime/code.py # 启动 review-changes看事件流
python s18_workflow_runtime/code.py resume # 用上次的 runId 续跑,每个 agent() 都命中 journal 缓存
```
观察:一次启动 → `async_launched` → 阶段切换/子agent进度推进 → `task_notification`;结果存在任务对象上。续跑的时候会显示 `agents=0 tokens=0`(全部命中缓存),结果和上次一字不差。
## 接下来
编排是在 agent 能力之上又加了一层:主循环管单步操作,脚本管整支队伍的流程。把工作写成确定、可恢复的脚本,模型就从"逐轮驱动者"变成了"被脚本调度的执行单元"。同一个 `agent()`,既能在主循环里被模型临场调用,也能在 workflow 里被脚本批量编排。
下一章:[s19 Goal Loop](../s19_goal_loop/) — 编排把工作分派给多个 agent下一章反过来一个目标把控制权重拉回主循环没达成就不让这一轮结束。
<!-- translation-sync: zh@v2, en@v2, ja@v2 -->

View File

@@ -0,0 +1,552 @@
"""
s18_workflow_runtime — minimal dynamic Workflow runtime
Idea:
s01-s17 build a single, model-driven agent loop. s18 adds a deterministic
orchestration LAYER on top: the main loop exposes a `Workflow` tool that
executes a script written with agent()/parallel()/pipeline()/phase(). One
call drives many subagents deterministically, reports progress, persists a
journal, and returns the result and task state. A runId can resume the work.
Run:
python s18_workflow_runtime/code.py
python s18_workflow_runtime/code.py resume
Implementation choices:
- MockAgentRunner is deterministic so resume behavior is reproducible.
- A workflow is a plain async Python function.
- Lifecycle and progress events expose each run's state.
- 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)
# ============================================================
# Workflow task lifecycle + progress events
# ============================================================
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 executing the script. It returns the result and task state and
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:]))

View File

@@ -0,0 +1,115 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 640" font-family="system-ui, -apple-system, sans-serif" role="img" aria-labelledby="title description">
<title id="title">Workflow Runtime execution flow</title>
<desc id="description">One Workflow tool call executes a complete workflow run. Lifecycle and progress events remain inside the call, which returns one tool result containing launch metadata, the result, and task state.</desc>
<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>
<rect width="960" height="640" rx="8" fill="#ffffff"/>
<text x="480" y="30" text-anchor="middle" fill="#1a1a1a" font-size="19" font-weight="700">Workflow Runtime — one Workflow call executes one complete run</text>
<text x="480" y="51" text-anchor="middle" fill="#888888" font-size="12">lifecycle and progress events are emitted during the call; one final tool_result returns to messages[]</text>
<!-- Main session loop -->
<rect x="20" y="68" width="920" height="132" rx="8" fill="#ffffff" stroke="#d0d0d0" stroke-width="1.5" stroke-dasharray="6,3"/>
<text x="40" y="88" fill="#1a1a1a" font-size="13" font-weight="700">Main session loop</text>
<path d="M 818 118 L 818 100 L 110 100 L 110 118" fill="none" stroke="#22c55e" stroke-width="1.5" stroke-dasharray="6,3" marker-end="url(#arrow-green)"/>
<text x="464" y="95" text-anchor="middle" fill="#22c55e" font-size="10" font-weight="600">append one tool_result to messages[]</text>
<rect x="42" y="118" width="136" height="54" rx="6" fill="#ffffff" stroke="#1a1a1a" stroke-width="1.5" stroke-dasharray="6,3"/>
<text x="110" y="141" text-anchor="middle" fill="#1a1a1a" font-size="12" font-weight="700" font-family="monospace">messages[]</text>
<text x="110" y="159" text-anchor="middle" fill="#888888" font-size="9">message history</text>
<line x1="178" y1="145" x2="218" y2="145" stroke="#22c55e" stroke-width="2" marker-end="url(#arrow-green)"/>
<rect x="220" y="118" width="92" height="54" rx="6" fill="#ffffff" stroke="#1a1a1a" stroke-width="1.5"/>
<text x="266" y="141" text-anchor="middle" fill="#1a1a1a" font-size="13" font-weight="700">LLM</text>
<text x="266" y="159" text-anchor="middle" fill="#888888" font-size="9">tool_use?</text>
<line x1="312" y1="145" x2="352" y2="145" stroke="#22c55e" stroke-width="2" marker-end="url(#arrow-green)"/>
<rect x="354" y="118" width="220" height="54" rx="6" fill="#ffffff" stroke="#1a1a1a" stroke-width="1.5"/>
<text x="464" y="140" text-anchor="middle" fill="#1a1a1a" font-size="11" font-weight="700" font-family="monospace">Workflow({script, args})</text>
<text x="464" y="159" text-anchor="middle" fill="#888888" font-size="8.5" font-family="monospace">resume_from_run_id?</text>
<rect x="715" y="110" width="205" height="70" rx="6" fill="#fafafa" stroke="#1a1a1a" stroke-width="1.5"/>
<text x="818" y="134" text-anchor="middle" fill="#1a1a1a" font-size="12" font-weight="700">tool_result</text>
<text x="818" y="153" text-anchor="middle" fill="#22c55e" font-size="9.5" font-weight="600" font-family="monospace">launched + result + task</text>
<text x="818" y="168" text-anchor="middle" fill="#888888" font-size="8.5">one return after the run</text>
<!-- Complete WorkflowTool.call lifecycle -->
<rect x="20" y="232" width="920" height="350" rx="8" fill="#ffffff" stroke="#d0d0d0" stroke-width="1.5" stroke-dasharray="6,3"/>
<text x="40" y="253" fill="#1a1a1a" font-size="13" font-weight="700">WorkflowTool.call — complete workflow task lifecycle</text>
<rect x="45" y="272" width="180" height="76" rx="6" fill="#ffffff" stroke="#1a1a1a" stroke-width="1.5"/>
<text x="135" y="294" text-anchor="middle" fill="#1a1a1a" font-size="11" font-weight="700" font-family="monospace">WorkflowTool.call</text>
<text x="135" y="314" text-anchor="middle" fill="#888888" font-size="9">validate meta · permission</text>
<text x="135" y="331" text-anchor="middle" fill="#888888" font-size="9" font-family="monospace">runId · taskId · envelope</text>
<line x1="225" y1="310" x2="263" y2="310" stroke="#22c55e" stroke-width="2" marker-end="url(#arrow-green)"/>
<rect x="265" y="272" width="180" height="76" rx="6" fill="#fafafa" stroke="#d0d0d0" stroke-width="1.5"/>
<text x="355" y="294" text-anchor="middle" fill="#1a1a1a" font-size="11" font-weight="700">Emit lifecycle</text>
<text x="355" y="314" text-anchor="middle" fill="#22c55e" font-size="9.5" font-weight="600" font-family="monospace">async_launched</text>
<text x="355" y="331" text-anchor="middle" fill="#888888" font-size="9.5" font-family="monospace">task_started</text>
<line x1="445" y1="310" x2="483" y2="310" stroke="#22c55e" stroke-width="2" marker-end="url(#arrow-green)"/>
<rect x="485" y="272" width="180" height="76" rx="6" fill="#ffffff" stroke="#1a1a1a" stroke-width="1.5" stroke-dasharray="6,3"/>
<text x="575" y="294" text-anchor="middle" fill="#1a1a1a" font-size="11" font-weight="700">Execute script</text>
<text x="575" y="314" text-anchor="middle" fill="#888888" font-size="9" font-family="monospace">phase · agent()</text>
<text x="575" y="331" text-anchor="middle" fill="#888888" font-size="9" font-family="monospace">parallel · pipeline</text>
<line x1="665" y1="310" x2="703" y2="310" stroke="#22c55e" stroke-width="2" marker-end="url(#arrow-green)"/>
<rect x="705" y="272" width="210" height="76" rx="6" fill="#fafafa" stroke="#d0d0d0" stroke-width="1.5"/>
<text x="810" y="294" text-anchor="middle" fill="#1a1a1a" font-size="11" font-weight="700">Finalize task</text>
<text x="810" y="314" text-anchor="middle" fill="#888888" font-size="9">write output · save last run</text>
<text x="810" y="331" text-anchor="middle" fill="#22c55e" font-size="9.5" font-weight="600" font-family="monospace">task_notification</text>
<!-- Script execution details -->
<line x1="575" y1="348" x2="575" y2="388" stroke="#22c55e" stroke-width="2" marker-end="url(#arrow-green)"/>
<text x="588" y="371" fill="#22c55e" font-size="9" font-weight="600">agent()</text>
<rect x="475" y="390" width="190" height="64" rx="6" fill="#ffffff" stroke="#1a1a1a" stroke-width="1.5"/>
<text x="570" y="413" text-anchor="middle" fill="#1a1a1a" font-size="11" font-weight="700">Subagents × N</text>
<text x="570" y="432" text-anchor="middle" fill="#888888" font-size="9">schema validation · token budget</text>
<text x="570" y="447" text-anchor="middle" fill="#888888" font-size="8.5">parallel work, structured results</text>
<line x1="665" y1="422" x2="703" y2="422" stroke="#22c55e" stroke-width="2" marker-end="url(#arrow-green)"/>
<text x="684" y="414" text-anchor="middle" fill="#22c55e" font-size="8.5" font-weight="600">record</text>
<rect x="705" y="390" width="190" height="64" rx="6" fill="#fafafa" stroke="#d0d0d0" stroke-width="1.5"/>
<text x="800" y="413" text-anchor="middle" fill="#1a1a1a" font-size="11" font-weight="700">Journal</text>
<text x="800" y="432" text-anchor="middle" fill="#888888" font-size="9" font-family="monospace">semantic key → result</text>
<text x="800" y="447" text-anchor="middle" fill="#888888" font-size="8.5">resume returns cached calls</text>
<path d="M 705 441 L 680 441 L 680 372 L 635 372 L 635 348" fill="none" stroke="#888888" stroke-width="1.5" stroke-dasharray="5,3" marker-end="url(#arrow-gray)"/>
<text x="670" y="365" text-anchor="end" fill="#888888" font-size="8.5">cached</text>
<!-- Events remain within the call; the call returns once -->
<rect x="45" y="490" width="470" height="58" rx="6" fill="#fafafa" stroke="#d0d0d0" stroke-width="1.5" stroke-dasharray="6,3"/>
<text x="280" y="512" text-anchor="middle" fill="#1a1a1a" font-size="10.5" font-weight="700">Lifecycle + progress events emitted during the call</text>
<text x="280" y="532" text-anchor="middle" fill="#888888" font-size="8.8" font-family="monospace">async_launched · task_started · workflow_phase / agent / log · task_notification</text>
<path d="M 810 348 L 810 472 L 738 472 L 738 488" fill="none" stroke="#22c55e" stroke-width="2" marker-end="url(#arrow-green)"/>
<rect x="560" y="490" width="355" height="58" rx="6" fill="#ffffff" stroke="#1a1a1a" stroke-width="1.5"/>
<text x="738" y="513" text-anchor="middle" fill="#1a1a1a" font-size="11" font-weight="700" font-family="monospace">return { launched, result, task }</text>
<text x="738" y="532" text-anchor="middle" fill="#888888" font-size="9">after task_notification</text>
<!-- Cross-lane call and return -->
<path d="M 464 172 L 464 215 L 135 215 L 135 270" fill="none" stroke="#22c55e" stroke-width="1.5" stroke-dasharray="6,3" marker-end="url(#arrow-green)"/>
<text x="250" y="209" text-anchor="middle" fill="#22c55e" font-size="9" font-weight="600">execute complete run</text>
<path d="M 915 519 L 934 519 L 934 211 L 818 211 L 818 180" fill="none" stroke="#22c55e" stroke-width="1.8" marker-end="url(#arrow-green)"/>
<text x="928" y="380" text-anchor="middle" fill="#22c55e" font-size="9" font-weight="600" transform="rotate(-90 928 380)">return once</text>
<text x="480" y="616" text-anchor="middle" fill="#888888" font-size="10">One return boundary: async_launched is a lifecycle event; launched + result + task return together.</text>
</svg>

After

Width:  |  Height:  |  Size: 9.4 KiB