Refine course progression and runtime safety

This commit is contained in:
Haoran
2026-08-11 15:13:13 +08:00
parent b36dbcd84f
commit ab35e59672
83 changed files with 5291 additions and 2267 deletions

View File

@@ -4,7 +4,7 @@
s01 → ... → s16 → [s17](../s17_integrated_harness/) → `s18` → [s19](../s19_goal_loop/)
> *"一次 tool_use跑完一整套编排"* — `Workflow` 工具启动一个确定、可恢复的脚本运行时,批量派出去一堆子 agent。
> *"一次 tool_use跑完一整套编排"* — `Workflow` 工具启动一个确定、可恢复的脚本运行时,协调多次 agent 调用
>
> **Harness 层**: 编排 — 在单 agent 循环之上,加一层确定的多 agent 脚本运行时。
@@ -22,7 +22,7 @@ s01 → ... → s16 → [s17](../s17_integrated_harness/) → `s18` → [s19](..
## 计划写在代码里,不是靠聊天一轮轮凑
在 harness 的工具池里加入一个 `Workflow` 工具。用户或模型给它一段脚本,脚本用 `agent() / parallel() / pipeline() / phase()` 这几个简单的原语,把编排写成确定的代码
在 harness 的工具池里加入一个 `Workflow` 工具。宿主注册由 `agent() / parallel() / pipeline() / phase()` 组成的可信脚本。模型只提供保存好的 workflow 名称、参数和可选的续跑 run ID不会提交可执行代码或元数据
主循环这边只看到一次 `tool_use`。脚本运行时runtime 会不断发出生命周期和进度事件,并把每一步写进磁盘上的 journal。脚本结束后这次调用返回启动信息、结果和任务状态。脚本里的中间结果存在变量里不会塞进对话历史占地方。下次用 `resume_from_run_id` 重启时,没改过的 `agent()` 直接命中 journal 缓存,直接用之前的结果,断点续跑。
@@ -41,29 +41,41 @@ async def sample_workflow(ctx, args):
## Workflow 工具:一次调用,完成整次运行
`Workflow` 就在主 agent 的工具池。用户可以要求运行一个保存好的 workflow模型也可以在任务匹配已知编排时选择这个工具;两种情况最终都只发出一次 `Workflow(...)` 工具调用。
`Workflow` 会加入 s17 宿主已有的工具池。用户可以要求运行一个保存好的 workflow模型也可以在任务匹配已知编排时选择这个工具。适配器会用名称查询宿主管理的 `WORKFLOWS` registry再把可信的元数据和函数交给运行时s17 的其他工具仍在同一个循环里可用。
工具收到后会解析参数、校验 meta 信息、过权限检查、注册一个本地 workflow 任务,并在执行脚本前发出 `async_launched`接下来依次发出进度事件和最终的 `task_notification`;调用返回启动信息、结果和任务状态。
模型可见的 schema 只接受 `name``args``resume_from_run_id`。名称未知或参数格式错误时,适配器会返回错误工具结果,不会让宿主循环退出。随后运行时校验已经注册的元数据、经过权限检查、注册本地 workflow 任务,并在执行脚本前发出 `async_launched`。进度事件和最终的 `task_notification` 随后到达;调用返回可写入 JSON 的启动信息、结果和任务状态。
```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_TOOL = {
"name": "Workflow",
"input_schema": {
"type": "object",
"properties": {
"name": {"type": "string"},
"args": {"type": "object"},
"resume_from_run_id": {"type": "string"},
},
"required": ["name"],
"additionalProperties": False,
},
}
async def run_workflow(name, args=None, resume_from_run_id=None):
meta, script_fn = WORKFLOWS[name]
out = await WorkflowTool().call(
meta, script_fn,
args=args,
resume_from_run_id=resume_from_run_id,
)
return {"launched": out["launched"], "result": out["result"],
"task": serialize_task(out["task"])}
```
## Workflow 元数据:启动前先校验
每个 workflow 都注册一元数据对象,包含 `name``description` 和可选的 `phases`。运行时会在执行任何 workflow 代码前校验它:`name``description` 用来标识任务,`phases` 给进度分组命名。
每个保存好的 workflow 都注册一份可信元数据,包含 `name``description` 和可选的 `phases`。运行时会在执行 workflow 代码前校验它:`name``description` 用来标识任务,`phases` 给进度显示分组命名。这些字段属于宿主 registry不是模型输入。
运行时在注册阶段直接拒绝错误输入并抛出 `WorkflowInputError`。这和 s14 校验 cron 表达式是一个思路:坏脚本别让它跑到执行的时候才炸
注册内容不合法时,运行时会在启动前抛出 `WorkflowInputError`。这和 s14 校验 cron 表达式是一个思路:保存好的 workflow 有问题,就不要等到执行时才发现
运行时会把 `meta.name` 用在本地产物文件名中,因此还要求它是 1-64 个字符的安全 slug只能包含字母、数字、`.``_``-`
@@ -85,7 +97,7 @@ def validate_meta(meta):
## 编排原语:就这几个,够写所有流程
脚本跑在一个独立的上下文里,能用的全局变量就这几个编排原语。脚本本身不直接读写文件、不跑 shell,真正的代码操作都由派出去的子 agent 用它们自己的工具权限完成。这些原语都是 `ExecutionState` 上的方法:
脚本收到一个只暴露少量编排原语的 `ExecutionState`本身不直接读写文件,也不运行 shell。生产集成可以在 `agent()` 后接真实 agent runner并保留 runner 自己的工具权限。本章使用 `MockAgentRunner`,让 journal 和续跑结果可以复现;示例中的审查发现是固定测试数据,不是真实代码审查结果。
| 原语 | 作用 |
|------|------|
@@ -140,7 +152,7 @@ class LocalWorkflowTask:
## 存储:快照 + journal断了能续
运行时把每次运行的数据存在 `s18_workflow_runtime/.runtime/`:快照 `<runId>.json`、输出 `<runId>.output.json`journal `<runId>.journal.jsonl`。快照与 journal 共享稳定的 `runId`,续跑时才能找到同一次运行的状态和已完成步骤
运行时把每次运行的数据存在 `s18_workflow_runtime/.runtime/`:快照 `<runId>.json`、输出 `<runId>.output.json`journal `<runId>.journal.jsonl` 和协调文件 `<runId>.lock`。每次新运行都会在打开 journal 前,用排他式文件创建预留新的 `runId`。整次执行和最终持久化期间都持有 run lock另一个进程不能同时 resume 同一次运行。快照记录 workflow 名称、参数和任务状态resume 会先验证已保存的快照和 journal再改动原有的成功产物
journal 是断点续跑的核心,它一条一条记下来每个 `agent()` 的结果:
@@ -172,11 +184,11 @@ if cached is not MISS:
## 确定性:能复现,续跑才有意义
续跑要能工作workflow 首先得可复现。稳定哈希和确定性的 runner 让同一份 workflow + 同样的参数产生同样的 key。因此 workflow 代码要避免不受控的时钟、随机数文件系统状态等会让 key 在两次运行间变化的输入
续跑要能工作workflow 首先得可复现。稳定哈希让同一份 workflow 同样的参数产生同样的 journal key本章的确定性 runner 还让示例结果保持一致。真实 runner 的内容可以变化,但语义调用 key 必须稳定,不能把不受控的时钟、随机数文件系统状态混进 key。
## 跑起来看看
示例 workflow `review-changes``pipeline` 让每个审查维度独立走"审计 → 验证"流程。审计用一个带 schema 的 `agent()` 找问题,验证用 `parallel()` 给每条发现各派一个对抗性验证的子 agent最后只留确认真实的问题按严重度排序
示例 workflow `review-changes` `pipeline` 让每个审查维度独立走审计 → 验证”。确定性 runner 在审计阶段生成结构化测试发现,在验证阶段生成测试结论。这样示例只关注 pipeline、结构校验、journal 和续跑,不把课程结果绑在某个模型的审查质量上
```python
async def sample_workflow(ctx, args):
@@ -206,24 +218,25 @@ async def sample_workflow(ctx, args):
|--|-----------|---------------------|
| 循环 | 单个、模型驱动 | 主循环不变;上面加一层确定的编排 |
| 谁决定下一步 | 模型逐轮决定 | 脚本预先写好编排流程 |
| 多 agent | s06 子 agent一次性派出去 | 脚本化、可复现、可恢复的批量编排 |
| 新增机制 | — | 脚本 DSL、任务生命周期、进度事件、journal/续跑、结构化输出、确定性 VM |
| 多 agent | s06 子 agent一次性派出去 | 通过 agent-runner 边界执行脚本化、可续跑的调用 |
| 新增机制 | — | 编排原语、宿主 registry 与工具适配器、任务生命周期、进度事件、journal/续跑、结构化输出 |
s18 不替换主循环,它只是在工具层暴露 `Workflow`,背后启动一个本地 workflow 运行时:一个 workflow 确定地驱动 N 个 agent 循环。s06 的子 agent 是模型临场派一次s18 把编排写成可以重放的脚本
s18 不替换主循环,它只是在工具层暴露 `Workflow`,背后启动一个本地 workflow 运行时:一份保存好的脚本通过 agent-runner 边界协调 N 次调用。s06 的子 agent 是模型临场派一次s18 把编排写成可续跑的宿主代码
## 试一下
```bash
python s18_workflow_runtime/code.py # 启动 review-changes看事件流
python s18_workflow_runtime/code.py # 真实 API模型可选择 Workflow 或任一 s17 工具
python s18_workflow_runtime/code.py demo # 运行确定性的 review-changes 测试数据并观察事件流
python s18_workflow_runtime/code.py resume # 用上次的 runId 续跑,每个 agent() 都命中 journal 缓存
```
观察:一次启动 → `async_launched` → 阶段切换/子agent进度推进 → `task_notification`;结果存在任务对象上。续跑的时候会显示 `agents=0 tokens=0`(全部命中缓存),结果和上次一字不差
默认命令里,可以让模型运行保存好的 `review-changes` workflow这次工具调用与继承自 s17 的工具走同一个循环和分发器。`demo` 命令直接运行确定性测试数据,便于重复观察生命周期和续跑。它会报告 11 次 runner 调用和 6 条测试发现;续跑时全部命中缓存,因此显示 `agents=0 tokens=0`
## 接下来
编排是在 agent 能力之上又加了一层:主循环管单步操作,脚本管整支队伍的流程。把工作写成确定、可恢复的脚本,模型就从"逐轮驱动者"变成了"被脚本调度的执行单元"。同一个 `agent()`,既能在主循环里被模型临场调用,也能在 workflow 里被脚本批量编排
编排是在 agent 能力之上再加一层:主循环管单步操作,保存好的脚本管固定流程。本章让 agent-runner 边界保持确定;换成真实 runner 后,实际工作内容会改变,但 workflow 的生命周期、journal 和续跑约定不变
下一章:[s19 Goal Loop](../s19_goal_loop/) — 编排把工作分派给多个 agent下一章反过来,一个目标把控制权拉回主循环,没达成就不让这一轮结束
下一章:[s19 Goal Loop](../s19_goal_loop/) — 编排把工作分派给多个 agent下一章用一个聚焦的循环把控制权拉回目标。未达成时继续,达成或触发安全出口时把控制权交还用户
<!-- translation-sync: zh@v2, en@v2, ja@v2 -->
<!-- translation-sync: zh@v6, en@v6, ja@v6 -->