ai-agent-book 精选快照(<2MB 代码与文档,来自 github.com/bojieli/ai-agent-book)
Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s
Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
.venv/
|
||||
venv/
|
||||
|
||||
# Env / secrets
|
||||
.env
|
||||
|
||||
# Generated artifacts
|
||||
output/
|
||||
logs/
|
||||
checkpoints/
|
||||
*.log
|
||||
!experiment_protocol.json
|
||||
!validation/
|
||||
!validation/**
|
||||
@@ -0,0 +1,637 @@
|
||||
# Asynchronous Agent with Parallel Execution and Interruption / 带并行执行和打断能力的异步 Agent
|
||||
|
||||
> Companion code for *AI Agents in Depth*, Chapter 6 — **Experiment 6-2 ★★★**. Event-driven async Agent framework (Flux): parallel tools, interrupt/cancel, state checkpoints.
|
||||
> 配套《深入理解 AI Agent》第 4 章 **实验 6-2 ★★★**。事件驱动异步 Agent 框架(Flux):并行工具、打断取消、状态检查点。
|
||||
|
||||
← [Chapter 4 index / 返回第 4 章目录](../README.md)
|
||||
|
||||
---
|
||||
|
||||
## English
|
||||
|
||||
This directory is the runnable code for Experiment 6-2. It implements the core of the event-driven asynchronous Agent framework (Flux) described in [`agent_framework_design.md`](./agent_framework_design.md).
|
||||
|
||||
Building on the simple event queue of 4-5, this experiment goes deeper into async Agents and focuses on four things: **async tool execution, event queues and batching, interruption, and cancel/status query for parallel tools**. The Agent must manage concurrent tasks, handle interrupt and recovery, and decide from real-time state.
|
||||
|
||||
Two usage paths:
|
||||
|
||||
- **Offline demos (recommended first; zero deps, no API key)**: three measurable capabilities—**parallel vs serial wall-clock, interrupt/cancel then recover, checkpoint persist and restore**. No network, no LLM, no need for `openai`; `python demo.py` runs as-is.
|
||||
- **LLM scenarios (four book verification scenes)**: decisions by a real LLM (default OpenAI `gpt-5.6-luna`, function calling); API key required.
|
||||
|
||||
Both paths share the same async runtime. Long-running work uses **real, allowlisted Python subprocesses**. No shell is opened: progress is parsed from child stdout, completion includes observed hashes/return codes, and cancellation terminates the child PID.
|
||||
|
||||
## Code map
|
||||
|
||||
- **Run first:** python demo.py (offline, no API key).
|
||||
- **Start here:** runtime.py::AgentRuntime
|
||||
- **Core behavior:** runtime.py::_dispatcher routes urgency; _handle_interrupt cancels at a safe point; run_llm_turn advances the trajectory.
|
||||
- **State / protocol:** Event, inbox, pending batch and checkpoint files.
|
||||
- **Verifier:** offline demo assertions and run_real_experiment.py evidence gates.
|
||||
- **Experiment variable:** serial vs parallel tools, interrupt timing and checkpoint restore.
|
||||
- **Skip on first pass:** provider clients and the frontend/demo formatting.
|
||||
|
||||
### Architecture
|
||||
|
||||
Corresponds to section 5 of the design doc—all single-threaded `asyncio`:
|
||||
|
||||
```
|
||||
┌──────────────┐
|
||||
user msg / interrupt ──▶ │ inbox │ all inbound raw events
|
||||
async task completion ──▶ │ (asyncio.Q) │
|
||||
└──────┬───────┘
|
||||
│
|
||||
┌──────────▼───────────┐ classify_urgency()
|
||||
│ _dispatcher │──▶ interrupt / immediate / deferred
|
||||
└──────────┬───────────┘
|
||||
┌────────────────┼───────────────────┐
|
||||
INTERRUPT │ IMMEDIATE│ DEFERRED│
|
||||
cancel current turn+async direct to work pending buffer;
|
||||
tools; leave trace batch when async.result arrives
|
||||
┌──────────▼───────────┐
|
||||
│ work │ event batches to process
|
||||
└──────────┬───────────┘
|
||||
┌──────────▼───────────┐
|
||||
│ _worker │ per batch: append trajectory -> run_llm_turn()
|
||||
│ turn_task cancelable│ (on interrupt, cancel this child task)
|
||||
└──────────────────────┘
|
||||
|
||||
TaskManager: bounded real subprocesses (start / query / cancel / cancel_all)
|
||||
natural completion -> inject as new event (async.result) into inbox
|
||||
```
|
||||
|
||||
Code files:
|
||||
|
||||
| File | Role |
|
||||
|------|------|
|
||||
| `events.py` | `Event` model (checkpoint `to_dict`/`from_dict`), event types, **urgency** `classify_urgency()` |
|
||||
| `tasks.py` | Allowlisted subprocess `TaskManager` (stdout progress, OS cancel/query by id, executable receipts, `snapshot`/`restore`) |
|
||||
| `analysis_worker.py` | Real executable that hashes and analyzes `book/chapter4.md` while emitting progress |
|
||||
| `run_real_experiment.py` | Durable, model-independent acceptance campaign for all four manuscript scenarios |
|
||||
| `runtime.py` | `AgentRuntime`: event loop, two processing modes, LLM function calling, tool exec, `save_checkpoint`/`load_checkpoint` |
|
||||
| `async_demos.py` | Three **offline demos** (no API key): parallel wall-clock, interrupt/recover, state checkpoint |
|
||||
| `demo.py` | Unified CLI (argparse subcommands): offline demos + four LLM scenarios |
|
||||
|
||||
#### Two event-processing mechanisms (design doc 5.1)
|
||||
|
||||
- **Cancellation-based**: urgent events (user “cancel/stop”) immediately cancel the in-flight LLM turn and all background async tools; write interrupt event + cancel receipts into the trajectory.
|
||||
- **Queued**: non-urgent events (supplementary instructions) go to a `pending` buffer without interrupting work; when an async tool finishes and emits `async.result`, pending events are batch-appended to the trajectory, then one LLM turn runs.
|
||||
|
||||
Urgency rules (simple and explainable):
|
||||
|
||||
1. Interrupt keywords (cancel/stop/停止…) → `INTERRUPT` (cancellation-based)
|
||||
2. A question (question mark or interrogative, e.g. “what time is it?”) → `IMMEDIATE` (reply now, **do not** cancel background tasks)
|
||||
3. Other supplementary instructions (e.g. “reply in Japanese”) → `DEFERRED` (queue, batch)
|
||||
|
||||
#### Async tools
|
||||
|
||||
`run_terminal_command` is **async**: it returns a `task_id` placeholder immediately, then starts an allowlisted child process with `asyncio.create_subprocess_exec` and `shell=False`. Progress comes from the process's stdout. On completion, file metrics, input/stdout hashes, PID, return code, and duration are injected as a **new event** (`async.result`). `cancel_task` sends termination to that PID. Also: `query_task` by id and `get_current_time` for immediate questions.
|
||||
|
||||
**Timeline acceleration**: one logical progress tick maps to `0.4` wall-clock seconds by default (`FLUX_TICK_REAL` tunable). The real executables retain the **3% / 2% / 1% per tick** rates and **50%** threshold.
|
||||
|
||||
### How to run
|
||||
|
||||
CLI entry is `demo.py` with argparse subcommands; `python demo.py --help` for full usage.
|
||||
|
||||
For canonical, machine-readable evidence across all four scenarios:
|
||||
|
||||
```bash
|
||||
python run_real_experiment.py --tick-real 0.15
|
||||
pytest -q test_tasks_env.py test_real_tasks.py
|
||||
```
|
||||
|
||||
The campaign takes about 20 seconds and writes per-scenario receipts, Japanese
|
||||
HTML, the integrated report, acceptance gates, and a hash manifest under
|
||||
`validation/experiment_6_2/`.
|
||||
|
||||
#### Offline demos (no API key, out of the box)
|
||||
|
||||
```bash
|
||||
cd chapter4/async-agent
|
||||
|
||||
python demo.py # default: run all three offline demos in order
|
||||
python demo.py offline # same: explicit sequential offline demos
|
||||
python demo.py parallel # capability 1: parallel vs serial wall-clock (prints speedup)
|
||||
python demo.py interrupt # capability 2: interrupt/cancel mid-task, then recover
|
||||
python demo.py state # capability 3: checkpoint persist + cross-session restore + verify
|
||||
```
|
||||
|
||||
These demos do not network, call LLMs, or require `openai`—pure `asyncio` measures parallel speedup, frozen state after interrupt, and checkpoint save/restore.
|
||||
|
||||
#### Tests (offline)
|
||||
|
||||
The automated regression tests live in `tests/` and do not require an API key.
|
||||
|
||||
```bash
|
||||
# From the repository root, include the dev extra for pytest:
|
||||
uv sync --locked --python 3.12 --extra ch4 --extra dev
|
||||
|
||||
# pip testing fallback:
|
||||
# python -m pip install -e ".[ch4,dev]"
|
||||
|
||||
cd chapter4/async-agent
|
||||
python -m pytest tests
|
||||
```
|
||||
|
||||
#### LLM verification scenarios (four book scenes; API key required)
|
||||
|
||||
```bash
|
||||
# From the repository root: use the shared Chapter 4 environment
|
||||
uv sync --locked --python 3.12 --extra ch4
|
||||
|
||||
# Activate it before changing directories:
|
||||
# macOS/Linux:
|
||||
source .venv/bin/activate
|
||||
# Windows PowerShell: .venv\Scripts\Activate.ps1
|
||||
# Windows cmd: .venv\Scripts\activate.bat
|
||||
|
||||
# pip fallback when uv is not installed:
|
||||
# python -m pip install -e ".[ch4]"
|
||||
|
||||
cd chapter4/async-agent
|
||||
|
||||
# Single-project compatibility path, still supported during migration:
|
||||
# python -m pip install -r requirements.txt
|
||||
|
||||
cp env.example .env # set OPENAI_API_KEY
|
||||
|
||||
python demo.py scenarios # all four scenarios
|
||||
python demo.py scenarios --scenario 1 # scenario 1 only (async exec + immediate question)
|
||||
python demo.py scenarios --scenario 3 # scenario 3 only (interrupt)
|
||||
```
|
||||
|
||||
Default OpenAI `gpt-5.6-luna`. Other OpenAI-compatible providers:
|
||||
|
||||
```bash
|
||||
# Moonshot (default model: reasoning model kimi-k3)
|
||||
LLM_PROVIDER=moonshot python demo.py scenarios --scenario 1
|
||||
# Volcengine ARK (LLM_MODEL = inference endpoint id)
|
||||
LLM_PROVIDER=ark LLM_MODEL=ep-xxxx python demo.py scenarios --scenario 1
|
||||
# Alibaba Cloud Model Studio / Bailian (Qwen)
|
||||
LLM_PROVIDER=dashscope DASHSCOPE_API_KEY=your-key LLM_MODEL=qwen3.7-plus python demo.py scenarios --scenario 1
|
||||
```
|
||||
|
||||
> **Universal OpenRouter fallback**: if `OPENAI_API_KEY` is unset (and not moonshot/ark), with `OPENROUTER_API_KEY` set, `demo.py` routes via OpenRouter and maps model ids to `provider/model` (`gpt-*` → `openai/…`, `claude-*` → `anthropic/claude-opus-4.8`, ids with `/` pass through). Or set `LLM_PROVIDER=openrouter` explicitly. Example:
|
||||
> `OPENROUTER_API_KEY=your-openrouter-api-key LLM_MODEL=openai/gpt-5.6-luna python demo.py scenarios --scenario 1`
|
||||
|
||||
> Moonshot defaults to **reasoning model `kimi-k3`** (older `kimi-k2-*-preview` / `moonshot-v1-*` are outdated/retired). Reasoning models need `temperature=1` and `max_tokens>=2048`; `demo.py` applies these automatically by model.
|
||||
|
||||
> Legacy: `python demo.py --scenario N` is equivalent to `scenarios --scenario N`.
|
||||
|
||||
Log sources are color-coded: `USER`, `AGENT`, `TOOL`, `TASK`, `TRAJ`, `STATE`, `SYSTEM`.
|
||||
|
||||
### Three offline capabilities (real measured output)
|
||||
|
||||
The following are excerpts from **real runs** (no API key).
|
||||
|
||||
#### Capability 1: parallel vs serial tools (`python demo.py parallel`)
|
||||
|
||||
Four independent read-only perception-style tools (read file / search / DB / vector retrieval): serial `await` vs parallel `asyncio.gather`:
|
||||
|
||||
```
|
||||
── 结果对比 ─────────────────────────────────────────────
|
||||
串行总耗时(Σ 各工具) 4.51s
|
||||
并行总耗时(gather) 1.50s
|
||||
并行理论下界(最慢单个) 1.50s
|
||||
加速比 = 串行 / 并行 3.00x
|
||||
─────────────────────────────────────────────────────────
|
||||
```
|
||||
|
||||
Wall-clock drops from sum-of-tools to max-single—quantifying “read-only perception tools naturally parallelize.”
|
||||
|
||||
#### Capability 2: interrupt / cancel / recover (`python demo.py interrupt`)
|
||||
|
||||
Three parallel background tasks; user asks a question first (does not block tasks), then sends “cancel”:
|
||||
|
||||
```
|
||||
[ 1.00s] USER | (即时提问)现在几点了?
|
||||
[ 1.00s] AGENT | 现在 00:14:26。三个后台任务仍在并行推进,未被这次提问阻塞。
|
||||
[ 2.00s] USER | (打断)取消
|
||||
[ 2.00s] TASK | T1 已被取消 🛑(进度停在 39%)
|
||||
[ 2.00s] TASK | T2 已被取消 🛑(进度停在 26%)
|
||||
[ 2.00s] TASK | T3 已被取消 🛑(进度停在 13%)
|
||||
|
||||
── 打断后各任务状态(进度冻结在中途)───────────────────
|
||||
task_id 命令 状态 进度
|
||||
T1 python analyze_fast.py cancelled 39%
|
||||
T2 python analyze_mid.py cancelled 26%
|
||||
T3 python analyze_slow.py cancelled 13%
|
||||
─────────────────────────────────────────────────────────
|
||||
[ 2.05s] SYSTEM | 打断处理完毕,系统恢复空闲,可继续接受新任务……
|
||||
[ 5.52s] TASK | T4 完成 ✅
|
||||
[ 5.52s] AGENT | 已从打断中恢复,新任务 T4 正常完成:……
|
||||
```
|
||||
|
||||
Interrupt freezes cancelled tasks’ progress; the runtime itself stays healthy and can finish new work immediately.
|
||||
|
||||
#### Capability 3: state checkpoint persist and restore (`python demo.py state`)
|
||||
|
||||
Session A produces a trajectory + two running background tasks, saved to `checkpoints/agent_state.json`; session B restores with a fresh runtime and verifies:
|
||||
|
||||
```
|
||||
── 恢复校验 ─────────────────────────────────────────────
|
||||
轨迹事件数 保存前 3 -> 恢复后 3 [一致 ✓]
|
||||
可重建 LLM 上下文消息 4 条(system + 轨迹回放)
|
||||
task_id 命令 保存前进度 恢复后状态 进度
|
||||
T1 python analyze_fast.py 21% suspended 21%
|
||||
T2 python analyze_slow.py 7% suspended 7%
|
||||
─────────────────────────────────────────────────────────
|
||||
```
|
||||
|
||||
Trajectory and task progress fully persist across sessions; running tasks restore as `suspended` with last known progress for upper layers to “re-run” or “continue from progress.”
|
||||
|
||||
### Four LLM verification scenarios
|
||||
|
||||
#### Scenario 1: async tool execution
|
||||
Agent runs a long terminal command; user inserts “what time is it?”. Because the long command is async and non-blocking, the Agent answers immediately with `get_current_time`, then presents analysis when the background task finishes.
|
||||
|
||||
#### Scenario 2: event queue and batching
|
||||
During a long task, user sends “reply in Japanese” then “format as a webpage.” These non-urgent instructions queue; on task completion the framework **batch-appends** them; the Agent outputs Japanese HTML.
|
||||
|
||||
#### Scenario 3: interruption
|
||||
During a long task, user says “cancel.” The framework cancels the current turn and background async tools, recording `user.interrupt` and a `system.note` with cancelled task ids.
|
||||
|
||||
#### Scenario 4: parallel cancel and status query
|
||||
User: “run these three scripts at once; when the first finishes, query the others’ progress; cancel any under 50%.” Speeds 3% / 2% / 1% per second. Agent starts three async tasks; after the fastest finishes, queries the others (~66% and ~33%), cancels the under-50% one, and reports when the rest complete.
|
||||
|
||||
### Real LLM scenario output (key fragments)
|
||||
|
||||
> Real calls to `gpt-5.6-luna` (OpenAI-compatible); timestamps are real seconds; need API key to reproduce.
|
||||
|
||||
**Scenario 1 (async + immediate question)**
|
||||
```
|
||||
[ 3.97s] AGENT | 任务已在后台启动(task_id:T1)。完成后我会根据日志分析结果给出结论。
|
||||
[ 4.96s] TASK | T1 `python analyze_logs.py` 进度 22% ← 任务仍在后台跑
|
||||
[ 5.19s] TOOL | get_current_time -> 2026-07-18 13:43:30 ← 即时提问先回应
|
||||
[ 6.91s] AGENT | 现在是 2026 年 7 月 18 日 13:43:30。
|
||||
[12.19s] TRAJ | + async.result 异步完成 T1 ← 真实结果作为新事件注入
|
||||
[16.67s] AGENT | 日志分析已完成,结论如下:共扫描 12,840 条记录… ← 再呈现分析
|
||||
```
|
||||
|
||||
**Scenario 2 (batching)**
|
||||
```
|
||||
[ 1.50s] SYSTEM | 事件进入排队缓冲(当前积压 1 条)
|
||||
[ 1.90s] SYSTEM | 事件进入排队缓冲(当前积压 2 条)
|
||||
[12.05s] TASK | T1 完成 ✅
|
||||
[12.05s] SYSTEM | 异步结果到达,批量处理 2 条积压的非紧急事件
|
||||
[12.06s] TRAJ | + async.result 异步完成 T1
|
||||
[12.06s] TRAJ | + user.input 记得最后用日语回复
|
||||
[12.06s] TRAJ | + user.input 把结果整理成一个网页(HTML)
|
||||
...
|
||||
[22.38s] AGENT | <!DOCTYPE html>…<h2>分析結論</h2>… (批量指令一次性满足:日语 + HTML)
|
||||
```
|
||||
|
||||
**Scenario 3 (interrupt)**
|
||||
```
|
||||
[ 2.40s] TASK | 启动异步任务 T1: `python analyze_logs.py` (速度 4%/模拟秒)
|
||||
[ 4.00s] USER | (interrupt) 取消
|
||||
[ 4.00s] TASK | T1 已被取消 🛑(进度停在 14%)
|
||||
[ 4.00s] TRAJ | + user.interrupt 用户打断:取消
|
||||
[ 4.00s] TRAJ | + system.note 打断回执,取消任务 ['T1']
|
||||
[ 5.04s] AGENT | 已停止后台任务 T1。
|
||||
```
|
||||
|
||||
**Scenario 4 (parallel + status + 50% cancel + report)**
|
||||
```
|
||||
[ 2.82s] TASK | 启动异步任务 T1: `python analyze_fast.py` (速度 3%/模拟秒)
|
||||
[ 2.82s] TASK | 启动异步任务 T2: `python analyze_mid.py` (速度 2%/模拟秒)
|
||||
[ 2.82s] TASK | 启动异步任务 T3: `python analyze_slow.py` (速度 1%/模拟秒)
|
||||
[16.47s] TASK | T1 完成 ✅ ← 最快脚本先完成
|
||||
[19.84s] TOOL | query_task(T2) -> running 84% ← 查询其余两个进度
|
||||
[19.84s] TOOL | query_task(T3) -> running 42%
|
||||
[21.93s] TOOL | cancel_task(T3) -> 已取消 (进度 47%) ← 未过 50%,取消
|
||||
[22.89s] TASK | T2 完成 ✅
|
||||
[26.50s] AGENT | ## 分析汇总报告 … analyze_slow.py:已取消(未超 50%)…
|
||||
```
|
||||
|
||||
### Notes
|
||||
|
||||
- **Offline demos (`parallel`/`interrupt`/`state`) need no API key and no `openai` package.**
|
||||
- **Only `scenarios` needs network and a valid API key** (`OPENAI_API_KEY`, or `MOONSHOT_API_KEY` / `ARK_API_KEY`).
|
||||
- LLM wording varies per run; the four scenarios’ **behavioral logic** is stable. Retry on occasional high latency.
|
||||
- Timeline is accelerated; larger `FLUX_TICK_REAL` is closer to book “tens of seconds”; too small may break scenario 4’s under-50% cancel window.
|
||||
- Terminal jobs are real allowlisted Python child processes. Arbitrary commands and shell syntax are rejected before task allocation.
|
||||
|
||||
---
|
||||
|
||||
## 中文
|
||||
|
||||
本目录是《深入理解 AI Agent》实验 6-2 的配套可运行代码,实现了设计文档
|
||||
[`agent_framework_design.md`](./agent_framework_design.md) 中描述的事件驱动异步 Agent 框架(Flux)的核心部分。
|
||||
|
||||
在 4-5 的简单事件队列之上,本实验进入异步 Agent 的深水区,聚焦四件事:
|
||||
**异步工具执行、事件队列与批量处理、打断机制、并行工具的取消与状态查询**。
|
||||
Agent 需要同时管理多个并发任务,处理打断与恢复,并根据实时状态动态决策。
|
||||
|
||||
本目录提供两条使用路径:
|
||||
|
||||
- **离线演示(推荐先跑,零依赖、无需 API key)**:把三项核心异步能力单独拎出来、
|
||||
用可测量的方式演示——**并行 vs 串行的墙钟时间对比、打断/取消后恢复、状态检查点持久化与恢复**。
|
||||
这条路径不联网、不调用 LLM,甚至不需要安装 `openai`,`python demo.py` 即可直接运行。
|
||||
- **LLM 场景(还原书中四个验证场景)**:Agent 的决策由真实 LLM(默认 OpenAI `gpt-5.6-luna`,
|
||||
function calling)完成,需要配置 API key。
|
||||
|
||||
两条路径共用同一套异步运行时;长任务都用**模拟的异步"终端命令"**(带进度输出)实现,绝不真跑危险命令。
|
||||
|
||||
### 一、架构
|
||||
|
||||
对应设计文档第 5 节的事件处理循环,全部基于 `asyncio` 单线程实现:
|
||||
|
||||
```
|
||||
┌──────────────┐
|
||||
用户消息 / 打断 ──▶ │ inbox │ 所有进来的原始事件
|
||||
异步任务完成通知 ──▶ │ (asyncio.Q) │
|
||||
└──────┬───────┘
|
||||
│
|
||||
┌──────────▼───────────┐ 判定紧急度 classify_urgency()
|
||||
│ _dispatcher │──▶ 打断 / 立即处理 / 排队
|
||||
└──────────┬───────────┘
|
||||
┌────────────────┼───────────────────┐
|
||||
INTERRUPT │ IMMEDIATE│ DEFERRED│
|
||||
取消当前turn+异步工具 直接入 work 进 pending 缓冲,
|
||||
并留痕 异步结果到达时批量追加
|
||||
┌──────────▼───────────┐
|
||||
│ work │ 待处理的事件批次
|
||||
└──────────┬───────────┘
|
||||
┌──────────▼───────────┐
|
||||
│ _worker │ 逐批:追加到轨迹 -> run_llm_turn()
|
||||
│ turn_task 可被取消 │ (打断时 cancel 掉这个子任务)
|
||||
└──────────────────────┘
|
||||
|
||||
TaskManager:管理受限的真实子进程(start / query / cancel / cancel_all)
|
||||
任务自然完成 -> 以"新事件"(async.result) 注入 inbox
|
||||
```
|
||||
|
||||
代码文件:
|
||||
|
||||
| 文件 | 作用 |
|
||||
|------|------|
|
||||
| `events.py` | 事件模型 `Event`(含检查点序列化 `to_dict`/`from_dict`)、事件类型、**紧急度判定** `classify_urgency()` |
|
||||
| `tasks.py` | 真实受限子进程 `TaskManager`(stdout 进度、PID 取消/查询、可执行回执、`snapshot`/`restore`) |
|
||||
| `analysis_worker.py` | 真实分析进程:读取并哈希 `book/chapter4.md`,从 stdout 输出进度 |
|
||||
| `run_real_experiment.py` | 覆盖书中四场景的持久化验收运行器 |
|
||||
| `runtime.py` | `AgentRuntime`:事件循环、两种处理机制、LLM function calling、工具执行、检查点 `save_checkpoint`/`load_checkpoint` |
|
||||
| `async_demos.py` | 三个**离线演示**(无需 API key):并行墙钟对比、打断/恢复、状态检查点 |
|
||||
| `demo.py` | 统一命令行入口(argparse 子命令):离线演示 + 四个 LLM 验证场景 |
|
||||
|
||||
#### 两种事件处理机制(设计文档 5.1)
|
||||
|
||||
- **取消式处理(Cancellation-Based)**:紧急事件(用户"取消/停止")到达时,
|
||||
立即取消正在进行的 LLM turn,并取消所有后台异步工具,把打断事件与取消回执写入轨迹。
|
||||
- **排队处理(Queued)**:非紧急事件(补充性指令)先进入 `pending` 缓冲,不打断正在进行的工作;
|
||||
当某个异步工具完成、产生 `async.result` 事件时,一次性把 `pending` 里的事件批量追加到轨迹,再触发一次 LLM。
|
||||
|
||||
紧急度判定规则(简单可解释):
|
||||
|
||||
1. 含打断关键词(取消/停止/stop…)→ `INTERRUPT`(取消式处理)
|
||||
2. 是一个提问(带问号或疑问词,如"现在几点了?")→ `IMMEDIATE`(立即回应,但**不**打断后台任务)
|
||||
3. 其它补充性指令(如"用日语回复")→ `DEFERRED`(排队,批量处理)
|
||||
|
||||
#### 异步工具
|
||||
|
||||
`run_terminal_command` 是**异步**工具:调用后立刻返回 `task_id` 占位符(不阻塞),
|
||||
随后用 `asyncio.create_subprocess_exec`(`shell=False`)启动白名单子进程;进度来自真实 stdout。
|
||||
完成后把 PID、返回码、输入/输出哈希和文件分析指标作为**新事件**(`async.result`)注入对话;
|
||||
取消操作会终止对应 PID。
|
||||
另有 `query_task` / `cancel_task` 按 ID 查询进度与取消,`get_current_time` 用于即时提问。
|
||||
|
||||
**时间轴加速**:为便于复现,一个逻辑进度 tick 默认映射为 `0.4` 真实秒(`FLUX_TICK_REAL` 可调)。
|
||||
真实子进程保留 **3% / 2% / 1% 每 tick** 与 **是否过 50%** 的判定逻辑。
|
||||
|
||||
### 二、运行
|
||||
|
||||
命令行入口是 `demo.py`,用 `argparse` 子命令组织,`python demo.py --help` 查看全部用法。
|
||||
|
||||
#### 离线演示(无需 API key,开箱即用)
|
||||
|
||||
```bash
|
||||
cd chapter4/async-agent
|
||||
|
||||
python demo.py # 默认:依次运行下面三个离线演示
|
||||
python demo.py offline # 同上:显式地依次运行三个离线演示
|
||||
python demo.py parallel # 能力一:并行 vs 串行工具调用的墙钟时间对比(打印加速比)
|
||||
python demo.py interrupt # 能力二:长任务运行中被打断/取消,随后系统恢复
|
||||
python demo.py state # 能力三:状态检查点持久化 + 跨会话恢复并校验
|
||||
```
|
||||
|
||||
这三个演示不联网、不调用 LLM,连 `openai` 都无需安装——用纯 `asyncio` 直接测量并行加速、
|
||||
打断后的状态冻结、以及检查点的落盘与还原。
|
||||
|
||||
#### 测试(离线)
|
||||
|
||||
自动化回归测试位于 `tests/`,不需要 API Key。
|
||||
|
||||
```bash
|
||||
# 在仓库根目录安装 pytest 所需的 dev extra:
|
||||
uv sync --locked --python 3.12 --extra ch4 --extra dev
|
||||
|
||||
# pip 测试兜底路径:
|
||||
# python -m pip install -e ".[ch4,dev]"
|
||||
|
||||
cd chapter4/async-agent
|
||||
python -m pytest tests
|
||||
```
|
||||
|
||||
#### LLM 验证场景(还原书中四个场景,需要 API key)
|
||||
|
||||
```bash
|
||||
# 在仓库根目录使用统一的第 4 章环境
|
||||
uv sync --locked --python 3.12 --extra ch4
|
||||
|
||||
# 切换目录前先激活环境:
|
||||
# macOS/Linux:
|
||||
source .venv/bin/activate
|
||||
# Windows PowerShell:.venv\Scripts\Activate.ps1
|
||||
# Windows cmd:.venv\Scripts\activate.bat
|
||||
|
||||
# 未安装 uv 时可用 pip 兜底:
|
||||
# python -m pip install -e ".[ch4]"
|
||||
|
||||
cd chapter4/async-agent
|
||||
|
||||
# 迁移期间仍支持单项目兼容路径:
|
||||
# python -m pip install -r requirements.txt
|
||||
|
||||
cp env.example .env # 填入 OPENAI_API_KEY
|
||||
|
||||
python demo.py scenarios # 依次运行全部四个场景
|
||||
python demo.py scenarios --scenario 1 # 只跑场景 1(异步执行 + 即时提问)
|
||||
python demo.py scenarios --scenario 3 # 只跑场景 3(打断机制)
|
||||
```
|
||||
|
||||
默认用 OpenAI `gpt-5.6-luna`。也可切换服务商(OpenAI 兼容接口):
|
||||
|
||||
```bash
|
||||
# Moonshot(默认模型为当前的推理模型 kimi-k3)
|
||||
LLM_PROVIDER=moonshot python demo.py scenarios --scenario 1
|
||||
# 火山方舟 ARK(LLM_MODEL 填推理接入点 ID)
|
||||
LLM_PROVIDER=ark LLM_MODEL=ep-xxxx python demo.py scenarios --scenario 1
|
||||
```
|
||||
|
||||
> **OpenRouter 通用兜底**:未配置 `OPENAI_API_KEY`(且未用 moonshot/ark provider)时,
|
||||
> 只要设置了 `OPENROUTER_API_KEY`,`demo.py` 会自动改走 OpenRouter,并把模型名映射为
|
||||
> `provider/model` 形式(`gpt-*` → `openai/…`、`claude-*` → `anthropic/claude-opus-4.8`、
|
||||
> 含 `/` 的原样透传)。也可显式 `LLM_PROVIDER=openrouter`。例如:
|
||||
> `OPENROUTER_API_KEY=your-openrouter-api-key LLM_MODEL=openai/gpt-5.6-luna python demo.py scenarios --scenario 1`
|
||||
|
||||
> Moonshot 默认走**推理模型 `kimi-k3`**(旧的 `kimi-k2-*-preview` 与 `moonshot-v1-*` 已过时/停用)。
|
||||
> 推理模型要求 `temperature=1` 且 `max_tokens>=2048`,`demo.py` 会按模型自动套用这套采样参数,无需手动配置。
|
||||
|
||||
> 兼容旧用法:`python demo.py --scenario N` 会自动等价为 `scenarios --scenario N`。
|
||||
|
||||
日志中不同来源用颜色区分:`USER`(用户)、`AGENT`(Agent 回复)、`TOOL`(工具调用)、
|
||||
`TASK`(后台异步任务)、`TRAJ`(轨迹留痕)、`STATE`(状态检查点)、`SYSTEM`(框架事件)。
|
||||
|
||||
### 三、离线演示的三项能力(真实测量输出)
|
||||
|
||||
以下三段均为**真实运行**输出节选(无需 API key),演示异步到底带来了什么。
|
||||
|
||||
#### 能力一:并行 vs 串行工具调用(`python demo.py parallel`)
|
||||
|
||||
四个相互独立的只读感知工具(读文件 / 搜索 / 查库 / 向量检索),串行逐个 `await`
|
||||
与并行 `asyncio.gather` 的墙钟时间对比:
|
||||
|
||||
```
|
||||
── 结果对比 ─────────────────────────────────────────────
|
||||
串行总耗时(Σ 各工具) 4.51s
|
||||
并行总耗时(gather) 1.50s
|
||||
并行理论下界(最慢单个) 1.50s
|
||||
加速比 = 串行 / 并行 3.00x
|
||||
─────────────────────────────────────────────────────────
|
||||
```
|
||||
|
||||
墙钟时间由「各工具求和」降到「取最大单个」——这正是书中「只读感知工具天然适合并行」的量化落点。
|
||||
|
||||
#### 能力二:打断 / 取消 / 恢复(`python demo.py interrupt`)
|
||||
|
||||
三个并行后台任务运行中,用户先即时提问(不阻塞任务),随后发出「取消」打断:
|
||||
|
||||
```
|
||||
[ 1.00s] USER | (即时提问)现在几点了?
|
||||
[ 1.00s] AGENT | 现在 00:14:26。三个后台任务仍在并行推进,未被这次提问阻塞。
|
||||
[ 2.00s] USER | (打断)取消
|
||||
[ 2.00s] TASK | T1 已被取消 🛑(进度停在 39%)
|
||||
[ 2.00s] TASK | T2 已被取消 🛑(进度停在 26%)
|
||||
[ 2.00s] TASK | T3 已被取消 🛑(进度停在 13%)
|
||||
|
||||
── 打断后各任务状态(进度冻结在中途)───────────────────
|
||||
task_id 命令 状态 进度
|
||||
T1 python analyze_fast.py cancelled 39%
|
||||
T2 python analyze_mid.py cancelled 26%
|
||||
T3 python analyze_slow.py cancelled 13%
|
||||
─────────────────────────────────────────────────────────
|
||||
[ 2.05s] SYSTEM | 打断处理完毕,系统恢复空闲,可继续接受新任务……
|
||||
[ 5.52s] TASK | T4 完成 ✅
|
||||
[ 5.52s] AGENT | 已从打断中恢复,新任务 T4 正常完成:……
|
||||
```
|
||||
|
||||
打断只冻结被取消任务的进度,运行时本身无损,随后能立即接受并跑完新任务。
|
||||
|
||||
#### 能力三:状态检查点持久化与恢复(`python demo.py state`)
|
||||
|
||||
会话 A 产生一段轨迹 + 两个运行中的后台任务,落盘为 `checkpoints/agent_state.json`;
|
||||
会话 B 用全新运行时从磁盘恢复并校验:
|
||||
|
||||
```
|
||||
── 恢复校验 ─────────────────────────────────────────────
|
||||
轨迹事件数 保存前 3 -> 恢复后 3 [一致 ✓]
|
||||
可重建 LLM 上下文消息 4 条(system + 轨迹回放)
|
||||
task_id 命令 保存前进度 恢复后状态 进度
|
||||
T1 python analyze_fast.py 21% suspended 21%
|
||||
T2 python analyze_slow.py 7% suspended 7%
|
||||
─────────────────────────────────────────────────────────
|
||||
```
|
||||
|
||||
轨迹与任务进度完整落盘并跨会话还原;运行中的任务恢复后标记为 `suspended`,保留最后已知进度,
|
||||
供上层决定「重跑」还是「按进度续跑」——这就是异步任务的状态管理。
|
||||
|
||||
### 四、四个 LLM 验证场景
|
||||
|
||||
#### 场景 1:异步工具执行
|
||||
Agent 执行一个长终端命令,期间用户插入提问"现在几点了?"。
|
||||
因为长命令是异步的、不阻塞,Agent 立即用 `get_current_time` 回应时间,
|
||||
等后台任务完成后再把分析结论呈现出来。
|
||||
|
||||
#### 场景 2:事件队列与批量处理
|
||||
Agent 执行长任务期间,用户连续发"记得用日语回复""整理成网页"。
|
||||
这两条是非紧急指令,先进入排队缓冲;任务完成时,框架把它们**一次性批量追加**到轨迹,
|
||||
Agent 再综合所有指令,输出日语的 HTML 结果。
|
||||
|
||||
#### 场景 3:打断机制
|
||||
Agent 执行长任务,用户发"取消"。框架立即取消当前执行流并取消后台异步工具,
|
||||
在轨迹中记录打断事件(`user.interrupt`)和取消回执(`system.note`,含被取消的 task_id)。
|
||||
|
||||
#### 场景 4:并行工具的取消与状态查询
|
||||
用户要求"同时运行这三个脚本,哪个先完成就查其余进度,未过 50% 就取消"。
|
||||
三个脚本速度分别为 3% / 2% / 1% 每秒。Agent 同时启动三个异步任务;
|
||||
最快的先完成后,Agent 查询另外两个(约 66% 与 33%),取消未过 50% 的那个,
|
||||
其余完成后整合出报告。
|
||||
|
||||
### 五、LLM 场景真实运行输出(关键片段)
|
||||
|
||||
> 以下均为真实调用 `gpt-5.6-luna`(OpenAI 兼容接口)的输出节选(时间戳为真实秒,需配置 API key 复现)。
|
||||
|
||||
**场景 1(异步执行 + 即时提问)**
|
||||
```
|
||||
[ 3.97s] AGENT | 任务已在后台启动(task_id:T1)。完成后我会根据日志分析结果给出结论。
|
||||
[ 4.96s] TASK | T1 `python analyze_logs.py` 进度 22% ← 任务仍在后台跑
|
||||
[ 5.19s] TOOL | get_current_time -> 2026-07-18 13:43:30 ← 即时提问先回应
|
||||
[ 6.91s] AGENT | 现在是 2026 年 7 月 18 日 13:43:30。
|
||||
[12.19s] TRAJ | + async.result 异步完成 T1 ← 真实结果作为新事件注入
|
||||
[16.67s] AGENT | 日志分析已完成,结论如下:共扫描 12,840 条记录… ← 再呈现分析
|
||||
```
|
||||
|
||||
**场景 2(批量处理)**
|
||||
```
|
||||
[ 1.50s] SYSTEM | 事件进入排队缓冲(当前积压 1 条)
|
||||
[ 1.90s] SYSTEM | 事件进入排队缓冲(当前积压 2 条)
|
||||
[12.05s] TASK | T1 完成 ✅
|
||||
[12.05s] SYSTEM | 异步结果到达,批量处理 2 条积压的非紧急事件
|
||||
[12.06s] TRAJ | + async.result 异步完成 T1
|
||||
[12.06s] TRAJ | + user.input 记得最后用日语回复
|
||||
[12.06s] TRAJ | + user.input 把结果整理成一个网页(HTML)
|
||||
...
|
||||
[22.38s] AGENT | <!DOCTYPE html>…<h2>分析結論</h2>… (批量指令一次性满足:日语 + HTML)
|
||||
```
|
||||
|
||||
**场景 3(打断)**
|
||||
```
|
||||
[ 2.40s] TASK | 启动异步任务 T1: `python analyze_logs.py` (速度 4%/模拟秒)
|
||||
[ 4.00s] USER | (interrupt) 取消
|
||||
[ 4.00s] TASK | T1 已被取消 🛑(进度停在 14%)
|
||||
[ 4.00s] TRAJ | + user.interrupt 用户打断:取消
|
||||
[ 4.00s] TRAJ | + system.note 打断回执,取消任务 ['T1']
|
||||
[ 5.04s] AGENT | 已停止后台任务 T1。
|
||||
```
|
||||
|
||||
**场景 4(并行 + 状态查询 + 按 50% 阈值取消 + 整合报告)**
|
||||
```
|
||||
[ 2.82s] TASK | 启动异步任务 T1: `python analyze_fast.py` (速度 3%/模拟秒)
|
||||
[ 2.82s] TASK | 启动异步任务 T2: `python analyze_mid.py` (速度 2%/模拟秒)
|
||||
[ 2.82s] TASK | 启动异步任务 T3: `python analyze_slow.py` (速度 1%/模拟秒)
|
||||
[16.47s] TASK | T1 完成 ✅ ← 最快脚本先完成
|
||||
[19.84s] TOOL | query_task(T2) -> running 84% ← 查询其余两个进度
|
||||
[19.84s] TOOL | query_task(T3) -> running 42%
|
||||
[21.93s] TOOL | cancel_task(T3) -> 已取消 (进度 47%) ← 未过 50%,取消
|
||||
[22.89s] TASK | T2 完成 ✅
|
||||
[26.50s] AGENT | ## 分析汇总报告 … analyze_slow.py:已取消(未超 50%)…
|
||||
```
|
||||
|
||||
### 六、注意事项
|
||||
|
||||
- **离线演示(`parallel`/`interrupt`/`state`)无需任何 API key、也无需安装 `openai`**,开箱即跑。
|
||||
- **只有 `scenarios` 子命令需要联网并配置有效的 API key**(`OPENAI_API_KEY`,或切换到
|
||||
`MOONSHOT_API_KEY` / `ARK_API_KEY`)。
|
||||
- LLM 决策由真实模型产生,输出措辞每次可能略有不同;四个场景的**行为逻辑**是稳定可复现的。
|
||||
若遇到 OpenAI 偶发的高延迟,重跑即可。
|
||||
- 时间轴已加速;把 `FLUX_TICK_REAL` 调大可让演示更接近书中"几十秒"的真实节奏,
|
||||
调小则更快(过小可能让场景 4 的"未过 50% 就取消"来不及判定)。
|
||||
- 终端任务是真实但受限的 Python 子进程;任意命令与 shell 语法会在分配任务 ID 前被拒绝。
|
||||
|
||||
---
|
||||
|
||||
## Notes / 说明
|
||||
|
||||
- Design details: [`agent_framework_design.md`](./agent_framework_design.md).
|
||||
- 设计细节见 [`agent_framework_design.md`](./agent_framework_design.md)。
|
||||
- Terminal jobs are real allowlisted child processes and never invoke a shell.
|
||||
- 终端任务是白名单真实子进程,且绝不调用 shell。
|
||||
@@ -0,0 +1,159 @@
|
||||
## Flux: An Event-Driven Framework for Asynchronous Agentic Workflows
|
||||
|
||||
**Abstract:** Flux is a software framework designed for building asynchronous and event-driven AI agents, with a strong emphasis on **enabling low-code development**. Drawing an analogy to a real human, Flux treats agents as entities whose state and understanding evolve based on their accumulating memory. Flux aims to empower users, **including those with limited programming experience**, to define complex agentic workflows primarily through **declarative configuration**, minimizing the need for writing extensive code. It supports persistent, per-user long-term memory, modular agent invocation, and seamless tool integration. Communication adheres to the Agent Messaging Protocol (AMP). Key features include asynchronous execution, optional synchronous tools, interrupt handling, streaming outputs, and a developer experience focused on ease of use and configuration over complex coding.
|
||||
|
||||
### 1. Introduction
|
||||
|
||||
Modern AI applications require agents capable of complex, stateful, and collaborative interactions. Flux addresses this need with an asynchronous, event-driven architecture inspired by human cognition and OS principles. Crucially, Flux is designed to **democratize agent development**. Instead of requiring deep programming expertise, it focuses on allowing developers to define agent behavior, logic, and workflows through **intuitive configuration files and well-defined prompts**. The goal is to provide a robust platform where the core complexity of asynchronous processing, state management, and communication is handled by the framework, freeing developers to concentrate on the agent's specific goals and capabilities.
|
||||
|
||||
Flux models an agent like a human, processing inputs, thinking internally, acting externally, and handling interruptions, all recorded in its memory. It supports both rollout-specific working memory (trajectory) and user-specific long-term memory. By leveraging LLMs for decision-making based on this memory and providing a **configuration-driven approach** to defining agents and tools, Flux facilitates the creation of sophisticated agents without demanding extensive coding skills, making agentic AI development more accessible.
|
||||
|
||||
### 2. Core Concepts
|
||||
|
||||
* **Agent:** An agent is an autonomous computational entity, analogous to a real human expert or assistant. It possesses a defined set of capabilities (tools/skills), operates based on internal logic (primarily LLM-driven decision-making informed by its memory), and interacts with its environment through events within the scope of an **AMP Session**. An agent's understanding and state within a specific rollout are derived from its accumulated **trajectory**. It can also access and modify **User Long-Term Memory** to inform its behavior based on past interactions with a specific user. Each agent definition serves as a blueprint.
|
||||
* **Rollout:** A rollout represents a specific, running instance of an agent executing its defined logic within the context of an **AMP Session**. It is typically initiated by an event within that session (e.g., a user message, an agent invocation). Each rollout maintains its own independent **trajectory** and lifecycle state. Importantly, a single AMP Session can contain multiple participants (users and agents), and thus may involve multiple concurrent Flux Rollouts (one for each active agent participant in the session).
|
||||
* **Event:** The fundamental unit representing any occurrence relevant to a rollout, forming the building blocks of the agent's **trajectory**. All interactions, internal processing steps, and external stimuli within the rollout's scope are captured as events, appended chronologically to the rollout's trajectory. Drawing inspiration from OS process states and signals, events are categorized as Inputs, Interrupts, Thinking, and Actions:
|
||||
* **Inputs:** Events originating externally to the agent's rollout, akin to sensory perception within the session.
|
||||
* `user.input`: Messages or actions from a specific human user *within the current AMP session*.
|
||||
* `agent.input`: Messages or results received from another invoked agent rollout *within the same AMP session*.
|
||||
* `tool.result`: Data returned from a completed tool execution (sync or async), including results from memory access tools.
|
||||
* `external.trigger`: Events from outside systems integrated with the framework (e.g., API webhook, database change, new social media post), potentially associated with the session or a user.
|
||||
* **Interrupts:** Events that signal a need to alter or halt the current flow of execution within the rollout, often requiring immediate attention.
|
||||
* `supervisor.instruction`: Commands from an administrative system or human supervisor pertaining to this rollout or session.
|
||||
* `user.interrupt`: User actions (from a specific user in the session) like clicking a 'stop' button or explicitly cancelling an operation related to this rollout.
|
||||
* `timer.trigger`: An event fired by a previously set timer associated with this rollout.
|
||||
* **Thinking:** Events representing the agent's internal cognitive processes or state changes within the rollout.
|
||||
* `agent.thought`: Internal reasoning steps, intermediate conclusions, or state changes logged by the agent for transparency or future context within its trajectory.
|
||||
* **Actions:** Events representing the agent's decisions to interact with or affect the external world (including other participants in the session or external systems) or schedule future events for this rollout.
|
||||
* `agent.output`: Messages or data prepared to be sent to a specific user (or all users) *within the current AMP session* (mediated via a tool call adhering to AMP).
|
||||
* `agent.escalation`: A request sent to a supervisor system regarding this rollout or session.
|
||||
* `tool.request`: An invocation request for an external tool, which might include tools for accessing/updating **User Long-Term Memory**.
|
||||
* `agent.invocation`: A request to create and start a new rollout for another agent *within the same AMP session*, initiating collaboration.
|
||||
* `agent.response`: A message sent back to the agent rollout that invoked the current one *within the same AMP session*.
|
||||
* `agent.interrupt`: A signal sent to interrupt another specific agent rollout *within the same AMP session*.
|
||||
* `timer.set`: An instruction to the framework to schedule a `timer.trigger` event for the future for this specific rollout.
|
||||
* **Trajectory:** The complete, time-ordered, immutable sequence of all events (Inputs, Interrupts, Thinking, and Actions) associated with a *specific rollout*. This **is** the agent's working memory for that rollout. It provides the primary context required by the LLM to understand the rollout's immediate history, its own past reasoning and actions *within that rollout*, and make informed decisions about the next thinking steps or actions for that rollout.
|
||||
* **User Long-Term Memory:** A persistent, key-value store associated with a unique user ID. This memory exists *across* different rollouts (AMP Sessions) involving that user. It's designed to hold relatively stable information like user preferences, summarized past interactions, contact details, or accumulated knowledge about the user relevant for personalization and continuity. Agents access and modify this memory explicitly via designated **Actions** (e.g., specific `tool.request` events). This is distinct from the rollout-specific trajectory.
|
||||
* **Rollout Business State:** (Optional) A developer-defined state representing the current logical phase or status of the rollout from the perspective of the agent's business logic (e.g., `needs_clarification`, `processing_request`, `waiting_for_payment`, `request_completed`). This is distinct from the framework's internal rollout lifecycle state (`running`, `waiting`) and complements the trajectory by providing a high-level summary of the rollout's progress according to the developer's intended workflow.
|
||||
* **Workflow:** A definition specifying the entry point agent and potential interactions. In Flux, workflows can be:
|
||||
* **Emergent (Low-Code Default):** Driven primarily by the LLM's decisions based on memory and available tools. This approach typically requires **minimal explicit workflow configuration**, relying on the LLM's reasoning capabilities guided by prompts.
|
||||
* **State-Guided (Optional/Advanced):** Influenced by developer-defined **Rollout Business States**. This offers more explicit control for complex scenarios but requires more configuration.
|
||||
|
||||
### 3. Architecture
|
||||
|
||||
Flux employs a modular architecture centered around asynchronous event processing within the context of AMP Sessions:
|
||||
|
||||
* **Rollout Manager:** Responsible for creating, tracking, and terminating agent rollouts, associating them with their corresponding AMP Session and participant ID. It assigns unique IDs to rollouts and manages their lifecycle state.
|
||||
* **Event Queue (per Rollout):** Each active rollout has an associated event queue where incoming events (Inputs, Interrupts) relevant to that agent's role in the session are placed.
|
||||
* **Agent Runtime:** The core execution engine for a single rollout. It comprises:
|
||||
* **Event Processor:** Dequeues events, appends them to the rollout's trajectory, and determines if an LLM invocation is needed.
|
||||
* **LLM Invoker:**
|
||||
* Formats the rollout's current trajectory into the structure expected by the configured LLM. Optionally includes the current **Rollout Business State**. May also optionally include relevant excerpts from **User Long-Term Memory** (retrieved via a previous Action or potentially through automatic framework injection based on configuration).
|
||||
* Constructs the final prompt using the agent's system prompt, the formatted trajectory, and the user prompt template.
|
||||
* Invokes the configured LLM API.
|
||||
* Parses the LLM's response (Thinking/Action events), handling streaming for incremental processing.
|
||||
* **Tool Executor:**
|
||||
* Receives Action events (`tool.request`, `agent.invocation`, `agent.output`, `update_rollout_state`, etc.).
|
||||
* Manages the execution of these actions, interacting with external tools, other agents within the session, the Communication Layer, potentially a **Long-Term Memory Service**, and updating the **Rollout Business State** if requested.
|
||||
* Handles async/sync execution logic and cancellation.
|
||||
* Generates corresponding Input events (`tool.result`, `agent.input`, `timer.trigger`) and places them back into the appropriate rollout's Event Queue.
|
||||
* **Communication Layer:** Handles external communication via the AMP specification for the session. Translates incoming AMP messages/requests (addressed to the agent this rollout represents) into Flux Input/Interrupt events for the rollout. Translates agent Action events (`agent.output`) into outgoing AMP messages/streams targeted at the correct participants within the session. Manages SSE connections.
|
||||
|
||||
### 4. Agent Definition
|
||||
|
||||
Agents are defined declaratively, primarily through **configuration files (e.g., YAML, JSON)**, aligning with the low-code philosophy. An agent definition typically includes:
|
||||
|
||||
* **Identifier:** Unique name/ID.
|
||||
* **System Prompt:** Defines persona, goals, constraints. **A key area for defining agent logic without code.**
|
||||
* **User Prompt Template:** Structures the prompt, potentially including placeholders for Rollout Business State. **Another key configuration point.**
|
||||
* **Model Configuration:** LLM choice and parameters (simple configuration).
|
||||
* **Tool Registry:** Lists available tools/agents. Referencing existing tools/agents is a simple configuration entry. Defining *new* tools may require code, but the framework provides clear interfaces and registration mechanisms to simplify this.
|
||||
* Standard external tools.
|
||||
* Memory tools (access/update can be explicit tool calls or potentially configured implicit actions, offering flexibility).
|
||||
* References to other registered Flux agents.
|
||||
* (Optional) Action for updating Rollout Business State (`update_rollout_state`).
|
||||
* **State Machine Definition (Advanced/Optional):** For agents requiring very specific, complex state management, developers *can* define explicit states and transitions. **This is not required for typical agents**, where state can be managed implicitly through the trajectory or LLM reasoning.
|
||||
* **Context Inheritance Policy (Advanced/Optional):** Configuration for how much context is passed to invoked agents. **Simple defaults are provided**, and explicit configuration is only needed for specialized use cases.
|
||||
* **Workflow Specification (Optional):** Can define entry points. More complex workflow logic is often better embedded within the system prompt or handled via the optional state mechanism, rather than requiring complex external workflow definitions.
|
||||
|
||||
**Core agent logic often resides within the prompts and the LLM's inherent capabilities, configured declaratively, rather than in complex code within the framework.**
|
||||
|
||||
### 5. Event Processing and LLM Interaction
|
||||
|
||||
The core loop for an active rollout:
|
||||
|
||||
1. **Event Dequeue:** Get the next event for this rollout.
|
||||
2. **Memory Update:** Append the event to the trajectory.
|
||||
3. **LLM Trigger Check:** Decide if LLM processing is needed.
|
||||
4. **Context Formatting:** Translate trajectory into LLM format. Include the current **Rollout Business State** if defined. Optionally include retrieved **User Long-Term Memory** data (via explicit tool result or automatic injection).
|
||||
5. **LLM Invocation:** Send context and prompts to the LLM.
|
||||
6. **Response Parsing & Streaming:** Parse LLM response into Thinking/Action events.
|
||||
7. **Thinking/Action Generation & Internal State Update:** Add generated Thinking and Action events to the trajectory. If the LLM generated an `update_rollout_state` action, the Agent Runtime immediately processes it here, updating the rollout's current business state field.
|
||||
8. **Dispatch Actions to Executor:** Send all other generated Action events (those requiring interaction with external tools, other agents, the communication layer, timers, etc. – e.g., `tool.request`, `agent.invocation`, `agent.output`, `timer.set`) to the Tool Executor for handling.
|
||||
9. **Loop/Wait:** Wait for the next event.
|
||||
|
||||
#### 5.1 Event Processing Mechanisms
|
||||
|
||||
Flux supports two dynamic event processing strategies for handling events in a rollout. The framework automatically selects the appropriate mechanism based on the urgency of the incoming event:
|
||||
|
||||
1. **Cancellation-Based Processing:** When an urgent event arrives (e.g., user interrupts, high-priority inputs), the framework immediately stops the current LLM thinking or any synchronous tool call. All queued events in the pending queue along with the new urgent event are immediately appended to the trajectory. The LLM is then invoked with the complete updated trajectory to process all accumulated events together. This approach ensures that urgent, potentially invalidating events receive immediate attention and the agent can make decisions with the most critical, up-to-date information.
|
||||
|
||||
2. **Queued Processing:** When a non-urgent event arrives, it is queued at the end of the pending queue without interrupting ongoing processing. When any tool call (synchronous or asynchronous) of the agent completes and returns a `tool.result` event, the framework checks the pending queue before invoking the LLM. If there are pending events, all events in the pending queue are immediately moved to the end of the trajectory, and then the LLM is invoked to process the updated trajectory. This approach allows the agent to complete ongoing operations while efficiently batching non-urgent events, balancing responsiveness with computational efficiency.
|
||||
|
||||
**Urgency Determination:** The framework classifies events based on their type and context:
|
||||
- **Urgent events:** User interrupts (`user.interrupt`), supervisor instructions (`supervisor.instruction`), explicit agent interrupts (`agent.interrupt`), and time-critical external triggers marked as urgent.
|
||||
- **Non-urgent events:** Regular user inputs (`user.input`), agent messages (`agent.input`), tool results (`tool.result`), timer triggers (`timer.trigger`), and standard external triggers.
|
||||
|
||||
This dynamic selection ensures optimal responsiveness for critical events while maintaining efficiency for routine operations.
|
||||
|
||||
### 6. Tool Execution
|
||||
|
||||
Tools are fundamental.
|
||||
|
||||
* **Definition:** Tools have a clear definition structure (name, description, parameters). Defining *new* tools involves implementing a defined interface, but *using* existing tools is purely configuration.
|
||||
* **Invocation:** Triggered by LLM via `tool.request` action based on prompt and available tools.
|
||||
* **Memory Tools:** Framework aims to provide flexible options (explicit call vs. implicit action) configurable by the developer.
|
||||
* **Execution:** Asynchronous/synchronous handling is managed by the framework, abstracted from the developer defining the agent.
|
||||
* **Results:** Fed back as events, handled by the framework.
|
||||
* **Cancellation:** Framework provides the mechanism.
|
||||
|
||||
### 7. Inter-Agent Communication (Actor Model within AMP Session)
|
||||
|
||||
Flux implements actor model principles constrained within an AMP Session:
|
||||
|
||||
* **Invocation:** `Agent A` (Rollout A) invokes `Agent B` by generating an `agent.invocation` action. The Rollout Manager creates Rollout B for Agent B *within the same AMP Session*. Rollout B's initial event is the invocation details from Rollout A.
|
||||
* **Communication:** Rollout B can send results/messages back to Rollout A using `agent.response` actions, which arrive as `agent.input` events at Rollout A. Rollout B can also send messages directly to users in the session via `agent.output` actions, handled by the Communication Layer. All communication is asynchronous and potentially streaming via AMP.
|
||||
* **Context Sharing:** Simple defaults are provided. Explicit configuration of context sharing is an advanced option.
|
||||
|
||||
### 8. State Management
|
||||
|
||||
Flux manages state layers, abstracting complexity:
|
||||
|
||||
* **Framework Rollout State:** Internal framework state.
|
||||
* **Trajectory:** Automatically maintained log.
|
||||
* **User Long-Term Memory:** Accessed via configured tools or actions.
|
||||
* **Developer-Defined Rollout Business State (Optional):** A straightforward key-value state field developers can optionally use and manage via configuration and LLM actions for more explicit control when needed.
|
||||
* **LLM Context:** Assembled automatically by the framework based on configuration and state.
|
||||
|
||||
### 9. Streaming and Communication Protocol
|
||||
|
||||
* **AMP Adherence:** Handled by the framework's Communication Layer.
|
||||
* **Input/Output Mapping:** Handled by the framework.
|
||||
* **Streaming LLM Output:** Handled by the framework.
|
||||
|
||||
### 10. Developer Experience
|
||||
|
||||
Flux is fundamentally designed for **ease of use and low-code agent development**:
|
||||
|
||||
* **Declarative First:** The primary way to define agents, their logic (via prompts), tool usage, and basic workflows is through **declarative configuration files** (e.g., YAML, JSON), not procedural code.
|
||||
* **Abstraction:** The complexities of asynchronous execution, event loops, state persistence, AMP communication, and streaming are **handled internally by the framework**, allowing developers to focus on *what* the agent should do, not *how* the underlying machinery works.
|
||||
* **Configuration over Code:** Core agent behavior, personality, and decision-making logic are primarily defined in system prompts and by selecting available tools in the configuration.
|
||||
* **Simplified Tooling:** While new tools require some code, the framework provides clear interfaces. A library of pre-built common tools (including memory access) further reduces the need for coding.
|
||||
* **Emergent Workflows:** Simple agents can often function effectively by letting the LLM decide the next steps based on the trajectory and available tools, requiring minimal explicit workflow definition.
|
||||
* **Optional Complexity:** Features like explicit Rollout Business States and detailed Context Inheritance policies are available for advanced users needing fine-grained control, but are **not required** for basic agent development.
|
||||
* **Observability:** Clear logging and tracing mechanisms are provided to understand agent behavior without needing to debug complex framework internals.
|
||||
* **(Future) Visual Tools:** The declarative nature of Flux lends itself well to potential future development of GUI or visual flowcharting tools for defining agents and workflows, further enhancing accessibility.
|
||||
|
||||
### 11. Conclusion
|
||||
|
||||
Flux offers a robust, event-driven architecture designed for building sophisticated asynchronous AI agents while prioritizing **low-code development and accessibility**. By abstracting framework complexities and enabling agent definition primarily through **declarative configuration and prompting**, Flux empowers a wider range of developers, including those with limited coding experience, to create powerful agentic solutions. Its support for multiple memory types, modularity, adherence to AMP, and focus on a simplified developer experience makes it a strong foundation for building the next generation of collaborative and stateful AI applications.
|
||||
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Bounded analysis executable used by the Experiment 6-2 task manager."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def analyze(path: Path, job: str) -> dict:
|
||||
data = path.read_bytes()
|
||||
text = data.decode("utf-8", errors="replace")
|
||||
lines = text.splitlines()
|
||||
headings = [line for line in lines if re.match(r"^#{1,6}\s", line)]
|
||||
return {
|
||||
"job": job,
|
||||
"input_path": str(path),
|
||||
"input_sha256": hashlib.sha256(data).hexdigest(),
|
||||
"bytes": len(data),
|
||||
"lines": len(lines),
|
||||
"heading_count": len(headings),
|
||||
"experiment_mentions": text.lower().count("实验"),
|
||||
"async_mentions": len(re.findall(r"异步|async", text, flags=re.IGNORECASE)),
|
||||
"error_keyword_count": len(re.findall(r"错误|error", text, flags=re.IGNORECASE)),
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--job", required=True,
|
||||
choices=["fast", "mid", "slow", "logs", "recovery"])
|
||||
parser.add_argument("--rate", required=True, type=float)
|
||||
parser.add_argument("--tick-real", required=True, type=float)
|
||||
parser.add_argument("--input", required=True, type=Path)
|
||||
args = parser.parse_args()
|
||||
if args.rate <= 0 or args.tick_real <= 0:
|
||||
raise SystemExit("rate and tick-real must be positive")
|
||||
if not args.input.is_file():
|
||||
raise SystemExit(f"input does not exist: {args.input}")
|
||||
progress = 0.0
|
||||
while progress < 100.0:
|
||||
time.sleep(args.tick_real)
|
||||
progress = min(100.0, progress + args.rate)
|
||||
print(f"PROGRESS {progress:.3f}", flush=True)
|
||||
print("RESULT " + json.dumps(analyze(args.input.resolve(), args.job),
|
||||
ensure_ascii=False, sort_keys=True), flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,220 @@
|
||||
"""离线演示:不依赖任何 LLM / API key,直接驱动异步运行时的底层原语。
|
||||
|
||||
`demo.py` 里的四个「场景」需要真实 LLM 做决策;本模块则把实验 6-2 的三项核心
|
||||
异步能力单独拎出来,用可测量、可复现的方式演示,**无需联网、无需 API key**:
|
||||
|
||||
- demo_parallel :并行 vs 串行工具调用的【墙钟时间】对比(真实测量,打印加速比)。
|
||||
- demo_interrupt :长任务运行中被【打断/取消】,随后系统【恢复】并接受新任务。
|
||||
- demo_state :Agent 状态【检查点持久化】到磁盘,再【跨会话恢复】并校验。
|
||||
|
||||
这三个演示共同回答「异步到底带来了什么」——用数字和状态变化说话,而不只是措辞。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import os
|
||||
import time
|
||||
|
||||
from runtime import AgentRuntime, format_log
|
||||
from events import Event, EventType
|
||||
from tasks import TaskManager
|
||||
import tasks
|
||||
|
||||
|
||||
class Logger:
|
||||
"""与 runtime 同款的彩色时间戳日志器(相对本次演示起点计时)。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.t0 = time.time()
|
||||
|
||||
def __call__(self, source: str, text: str) -> None:
|
||||
print(format_log(self.t0, source, text), flush=True)
|
||||
|
||||
|
||||
def banner(title: str) -> None:
|
||||
print("\n" + "=" * 78)
|
||||
print(f" {title}")
|
||||
print("=" * 78, flush=True)
|
||||
|
||||
|
||||
# ============================ 1. 并行 vs 串行 ============================
|
||||
|
||||
# 一组相互独立的【只读感知工具】(读文件 / 搜索 / 查库 / 向量检索)。
|
||||
# 只读、无副作用,因此可以安全地并行——这正是书中「感知工具天然适合并行」的落点。
|
||||
_PERCEIVE_TOOLS = [
|
||||
("read_config.json", 0.8),
|
||||
("web_search(‘异步 Agent’)", 1.2),
|
||||
("db_query(orders)", 1.5),
|
||||
("vector_lookup(memory)", 1.0),
|
||||
]
|
||||
|
||||
|
||||
async def _perceive(name: str, latency: float, log: Logger) -> tuple[str, float, float]:
|
||||
"""模拟一次带 I/O 延迟的只读感知调用;返回 (名称, 标称延迟, 实测耗时)。"""
|
||||
t0 = time.time()
|
||||
log("TOOL", f"→ {name} 启动(模拟 I/O 耗时 {latency:.1f}s)")
|
||||
await asyncio.sleep(latency)
|
||||
dt = time.time() - t0
|
||||
log("TOOL", f"✓ {name} 完成(实测 {dt:.2f}s)")
|
||||
return name, latency, dt
|
||||
|
||||
|
||||
async def demo_parallel() -> None:
|
||||
banner("能力一|并行工具调用:并行 vs 串行的墙钟时间对比")
|
||||
log = Logger()
|
||||
log("SYSTEM", "有 4 个相互独立的只读感知工具需要调用(无副作用,可安全并行)。")
|
||||
|
||||
# —— 串行:一个 await 完再 await 下一个 ——
|
||||
log("SYSTEM", "\033[0m[串行] 逐个 await(同步 ReAct 的默认做法)……")
|
||||
seq_start = time.time()
|
||||
for name, lat in _PERCEIVE_TOOLS:
|
||||
await _perceive(name, lat, log)
|
||||
seq_total = time.time() - seq_start
|
||||
|
||||
# —— 并行:一次性发起,asyncio.gather 并发等待 ——
|
||||
log("SYSTEM", "\033[0m[并行] 一次性发起,asyncio.gather 并发等待……")
|
||||
par_start = time.time()
|
||||
await asyncio.gather(*[_perceive(name, lat, log) for name, lat in _PERCEIVE_TOOLS])
|
||||
par_total = time.time() - par_start
|
||||
|
||||
slowest = max(lat for _, lat in _PERCEIVE_TOOLS)
|
||||
speedup = seq_total / par_total if par_total else float("inf")
|
||||
|
||||
print("\n ── 结果对比 ─────────────────────────────────────────────")
|
||||
print(f" {'工具':<26}{'标称延迟':>10}")
|
||||
for name, lat in _PERCEIVE_TOOLS:
|
||||
print(f" {name:<26}{lat:>8.1f}s")
|
||||
print(" ─────────────────────────────────────────────────────────")
|
||||
print(f" {'串行总耗时(Σ 各工具)':<26}{seq_total:>8.2f}s")
|
||||
print(f" {'并行总耗时(gather)':<26}{par_total:>8.2f}s")
|
||||
print(f" {'并行理论下界(最慢单个)':<26}{slowest:>8.2f}s")
|
||||
print(f" {'加速比 = 串行 / 并行':<26}{speedup:>8.2f}x")
|
||||
print(" ─────────────────────────────────────────────────────────")
|
||||
print(" 结论:独立的只读调用并行化后,墙钟时间由「求和」降到「取最大」。\n")
|
||||
|
||||
|
||||
# ============================ 2. 打断 / 取消 / 恢复 ============================
|
||||
|
||||
async def demo_interrupt() -> None:
|
||||
banner("能力二|打断与取消:长任务运行中被打断,随后系统恢复")
|
||||
tasks.TICK_REAL = 0.15 # 本演示放慢节奏,留出「跑到一半再打断」的时间窗口
|
||||
log = Logger()
|
||||
completed: list = []
|
||||
|
||||
async def on_complete(state) -> None:
|
||||
completed.append(state)
|
||||
|
||||
tm = TaskManager(on_complete=on_complete, log=log)
|
||||
|
||||
# 1) 并行启动三个后台异步任务
|
||||
log("SYSTEM", "启动三个并行后台分析任务(fast/mid/slow)……")
|
||||
for cmd in ["python analyze_fast.py", "python analyze_mid.py", "python analyze_slow.py"]:
|
||||
tm.start(cmd)
|
||||
|
||||
# 2) 运行期间用户即时提问 —— 后台任务不被阻塞
|
||||
await asyncio.sleep(1.0)
|
||||
now = datetime.datetime.now().strftime("%H:%M:%S")
|
||||
log("USER", "(即时提问)现在几点了?")
|
||||
log("AGENT", f"现在 {now}。三个后台任务仍在并行推进,未被这次提问阻塞。")
|
||||
|
||||
# 3) 跑到中途,用户发出打断 —— 立即取消所有在跑的任务
|
||||
await asyncio.sleep(1.0)
|
||||
log("USER", "(打断)取消")
|
||||
cancelled = tm.cancel_all()
|
||||
await asyncio.sleep(0.05) # 让 CancelledError 在各协程内落地
|
||||
log("SYSTEM", f"已执行打断:取消了 {cancelled}(进度在被取消处冻结)")
|
||||
|
||||
print("\n ── 打断后各任务状态(进度冻结在中途)───────────────────")
|
||||
print(f" {'task_id':<8}{'命令':<26}{'状态':<12}{'进度':>6}")
|
||||
for s in tm.all_states():
|
||||
print(f" {s.task_id:<8}{s.command:<26}{s.status:<12}{s.progress:>5.0f}%")
|
||||
print(" ─────────────────────────────────────────────────────────")
|
||||
|
||||
# 4) 恢复:executor 依然健康,接受并跑完一个新任务
|
||||
log("SYSTEM", "打断处理完毕,系统恢复空闲,可继续接受新任务……")
|
||||
fresh = tm.start("python re_run_summary.py")
|
||||
await fresh._task
|
||||
log("AGENT", f"已从打断中恢复,新任务 {fresh.task_id} 正常完成:"
|
||||
f"{completed[-1].result[:36]}……")
|
||||
print(" 结论:打断只冻结被取消的任务,运行时本身无损,可立即继续工作。\n")
|
||||
|
||||
|
||||
# ============================ 3. 状态检查点:持久化 / 恢复 ============================
|
||||
|
||||
def _seed_trajectory(rt: AgentRuntime) -> None:
|
||||
"""给运行时灌入一段「已发生」的对话轨迹,模拟会话进行到一半。"""
|
||||
rt._append(Event(EventType.USER_INPUT,
|
||||
message={"role": "user", "content": "分析今天的日志并总结异常"},
|
||||
label="用户消息:分析日志"))
|
||||
rt._append(Event(EventType.AGENT_TOOL_CALL,
|
||||
message={"role": "assistant", "content": "好的,我这就在后台启动分析。",
|
||||
"tool_calls": [{"id": "call_1", "type": "function",
|
||||
"function": {"name": "run_terminal_command",
|
||||
"arguments": '{"command": "python analyze_fast.py"}'}}]},
|
||||
label="调用工具 run_terminal_command"))
|
||||
rt._append(Event(EventType.TOOL_RESULT,
|
||||
message={"role": "tool", "tool_call_id": "call_1",
|
||||
"content": "命令已在后台异步启动。task_id=T1。"},
|
||||
label="工具结果 run_terminal_command"))
|
||||
|
||||
|
||||
async def demo_state() -> None:
|
||||
banner("能力三|状态管理:检查点持久化与跨会话恢复")
|
||||
tasks.TICK_REAL = 0.15
|
||||
ckpt_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "checkpoints")
|
||||
os.makedirs(ckpt_dir, exist_ok=True)
|
||||
path = os.path.join(ckpt_dir, "agent_state.json")
|
||||
|
||||
# —— 会话 A:产生一段轨迹 + 两个仍在运行的后台任务,然后落盘 ——
|
||||
log = Logger()
|
||||
log("SYSTEM", "会话 A 开始:构造轨迹并启动两个后台任务……")
|
||||
rt_a = AgentRuntime(client=None, model="demo-offline")
|
||||
rt_a._t0 = log.t0 # 让两个运行时共用同一时间基准,便于观察
|
||||
_seed_trajectory(rt_a)
|
||||
rt_a.tasks.start("python analyze_fast.py") # 进行中
|
||||
rt_a.tasks.start("python analyze_slow.py") # 进行中
|
||||
await asyncio.sleep(1.2) # 让进度累积到中途
|
||||
|
||||
before_traj = len(rt_a.trajectory)
|
||||
before_tasks = {s.task_id: (s.status, s.progress) for s in rt_a.tasks.all_states()}
|
||||
rt_a.save_checkpoint(path)
|
||||
|
||||
# 模拟进程退出:取消掉活着的协程
|
||||
rt_a.tasks.cancel_all()
|
||||
await asyncio.sleep(0.05)
|
||||
log("SYSTEM", "会话 A 结束(进程退出,内存中的运行时已销毁)。")
|
||||
|
||||
# —— 会话 B:全新运行时,从磁盘恢复 ——
|
||||
log("SYSTEM", "会话 B 开始:新建空运行时,从检查点恢复……")
|
||||
rt_b = AgentRuntime(client=None, model="demo-offline")
|
||||
rt_b._t0 = log.t0
|
||||
data = rt_b.load_checkpoint(path)
|
||||
|
||||
after_traj = len(rt_b.trajectory)
|
||||
msgs = rt_b.build_messages() # 证明恢复后能重建可喂给 LLM 的上下文
|
||||
|
||||
print("\n ── 恢复校验 ─────────────────────────────────────────────")
|
||||
print(f" 轨迹事件数 保存前 {before_traj} -> 恢复后 {after_traj} "
|
||||
f"[{'一致 ✓' if before_traj == after_traj else '不一致 ✗'}]")
|
||||
print(f" 可重建 LLM 上下文消息 {len(msgs)} 条(system + 轨迹回放)")
|
||||
print(f" {'task_id':<8}{'命令':<26}{'保存前进度':>10} {'恢复后状态':<12}{'进度':>6}")
|
||||
for rec in data["tasks"]:
|
||||
tid = rec["task_id"]
|
||||
before = before_tasks.get(tid, ("-", 0.0))
|
||||
st = rt_b.tasks.query(tid)
|
||||
print(f" {tid:<8}{rec['command']:<26}{before[1]:>9.0f}% "
|
||||
f"{st.status:<12}{st.progress:>5.0f}%")
|
||||
print(" ─────────────────────────────────────────────────────────")
|
||||
print(f" 检查点文件:{path}")
|
||||
print(" 结论:轨迹与任务进度完整落盘并跨会话还原;运行中的任务被标记为 suspended,")
|
||||
print(" 保留了最后已知进度,供上层决定「重跑」还是「按进度续跑」。\n")
|
||||
|
||||
|
||||
# 供 demo.py 复用的离线演示注册表
|
||||
OFFLINE_DEMOS = {
|
||||
"parallel": demo_parallel,
|
||||
"interrupt": demo_interrupt,
|
||||
"state": demo_state,
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
"""实验 6-2 命令行入口:带并行执行、打断/取消与状态管理的异步 Agent。
|
||||
|
||||
本脚本提供两类演示,用子命令区分:
|
||||
|
||||
【离线演示】不需要任何 API key,直接测量异步运行时的底层行为——
|
||||
python demo.py parallel 并行 vs 串行工具调用的墙钟时间对比(打印加速比)
|
||||
python demo.py interrupt 长任务运行中被打断/取消,随后系统恢复
|
||||
python demo.py state Agent 状态检查点持久化 + 跨会话恢复并校验
|
||||
python demo.py offline 依次运行上面全部三个离线演示(默认行为)
|
||||
|
||||
【LLM 场景】需要 OPENAI_API_KEY(或 MOONSHOT/ARK),由真实模型做决策——
|
||||
python demo.py scenarios 依次运行书中四个验证场景
|
||||
python demo.py scenarios --scenario 1 只跑场景 1(异步执行 + 即时提问)
|
||||
python demo.py scenarios --scenario 3 只跑场景 3(打断机制)
|
||||
|
||||
不带任何子命令时运行【离线演示】,因此开箱即用、无需联网。
|
||||
为兼容旧用法,`python demo.py --scenario N` 等价于 `scenarios --scenario N`。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
from async_demos import OFFLINE_DEMOS, banner
|
||||
from runtime import AgentRuntime
|
||||
|
||||
# openai 仅在运行 LLM 场景时才惰性导入;离线演示不碰它,保证无 key/无 openai 也能跑。
|
||||
|
||||
|
||||
def _completion_params_for(model: str) -> dict:
|
||||
"""按模型返回安全的采样参数。
|
||||
|
||||
Moonshot kimi-k3 是【推理模型】:必须 temperature=1 且 max_tokens>=2048,
|
||||
否则可能报错或截断。其余模型用 temperature=0.2 保证决策稳定。
|
||||
"""
|
||||
if model.startswith("kimi-k3"):
|
||||
return {"temperature": 1, "max_tokens": 4096}
|
||||
return {"temperature": 0.2}
|
||||
|
||||
|
||||
def _map_model_for_openrouter(model: str) -> str:
|
||||
"""把常见模型名映射成 OpenRouter 的 `provider/model` 形式。
|
||||
|
||||
- 已含 "/" 的 id(如 anthropic/claude-opus-4.8、google/gemini-2.5-pro)原样透传。
|
||||
- gpt-*/o1-*/o3-*/o4-* -> openai/…
|
||||
- claude-* -> anthropic/claude-opus-4.8
|
||||
- 其它保持原样(交给 OpenRouter 校验)。
|
||||
"""
|
||||
if "/" in model:
|
||||
return model
|
||||
m = model.lower()
|
||||
if m.startswith(("gpt-", "o1-", "o3-", "o4-")):
|
||||
return f"openai/{model}"
|
||||
if m.startswith("claude-"):
|
||||
return "anthropic/claude-opus-4.8"
|
||||
return model
|
||||
|
||||
|
||||
def make_client():
|
||||
"""按 LLM_PROVIDER 选择可用的模型服务(默认 openai)。
|
||||
|
||||
返回 (client, model, completion_params)。
|
||||
|
||||
通用兜底:当直连 provider 的 key 缺失、但存在 OPENROUTER_API_KEY 时,
|
||||
自动改走 OpenRouter(api_key=OPENROUTER_API_KEY,base_url=openrouter.ai/api/v1,
|
||||
并把模型名映射成 provider/model 形式),从而"有 OpenRouter key 就能跑"。
|
||||
"""
|
||||
from openai import AsyncOpenAI # 惰性导入:离线演示无需安装 openai
|
||||
provider = os.getenv("LLM_PROVIDER", "openai").lower()
|
||||
if provider in {"dashscope", "qwen", "bailian"}:
|
||||
key = os.environ["DASHSCOPE_API_KEY"]
|
||||
model = os.getenv("LLM_MODEL", "qwen3.7-plus")
|
||||
base_url = os.getenv(
|
||||
"DASHSCOPE_BASE_URL",
|
||||
"https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
)
|
||||
client = AsyncOpenAI(api_key=key, base_url=base_url)
|
||||
return client, model, _completion_params_for(model)
|
||||
if provider == "moonshot":
|
||||
key = os.environ["MOONSHOT_API_KEY"]
|
||||
# 默认用当前的推理模型 kimi-k3(旧的 kimi-k2-*-preview 与 moonshot-v1-* 均已过时/停用)。
|
||||
model = os.getenv("LLM_MODEL", "kimi-k3")
|
||||
client = AsyncOpenAI(api_key=key, base_url="https://api.moonshot.cn/v1")
|
||||
return client, model, _completion_params_for(model)
|
||||
if provider == "ark":
|
||||
key = os.environ["ARK_API_KEY"]
|
||||
model = os.getenv("LLM_MODEL") # ARK 需要填 endpoint id
|
||||
if not model:
|
||||
raise SystemExit("使用 ARK 时请设置 LLM_MODEL 为你的推理接入点 ID")
|
||||
client = AsyncOpenAI(api_key=key, base_url="https://ark.cn-beijing.volces.com/api/v3")
|
||||
return client, model, _completion_params_for(model)
|
||||
if provider == "openrouter":
|
||||
key = os.environ["OPENROUTER_API_KEY"]
|
||||
model = _map_model_for_openrouter(os.getenv("LLM_MODEL", "openai/gpt-5.6-luna"))
|
||||
client = AsyncOpenAI(api_key=key, base_url="https://openrouter.ai/api/v1")
|
||||
return client, model, _completion_params_for(model)
|
||||
key = os.getenv("OPENAI_API_KEY")
|
||||
or_key = os.getenv("OPENROUTER_API_KEY")
|
||||
model = os.getenv("LLM_MODEL", "gpt-5.6-luna")
|
||||
# gpt-5.x(含 gpt-5.6*)直连 OpenAI 需要组织验证;只要有 OPENROUTER_API_KEY,
|
||||
# 就优先走 OpenRouter;直连 OPENAI_API_KEY 缺失时同样兜底到 OpenRouter。
|
||||
if or_key and (not key or model.lower().startswith("gpt-5")):
|
||||
mapped = _map_model_for_openrouter(model)
|
||||
client = AsyncOpenAI(api_key=or_key, base_url="https://openrouter.ai/api/v1")
|
||||
return client, mapped, _completion_params_for(mapped)
|
||||
if key:
|
||||
base = os.getenv("OPENAI_BASE_URL")
|
||||
client = AsyncOpenAI(api_key=key, base_url=base) if base else AsyncOpenAI(api_key=key)
|
||||
return client, model, _completion_params_for(model)
|
||||
raise SystemExit(
|
||||
"未找到可用的 LLM Key。请设置以下任意一项:"
|
||||
"OPENAI_API_KEY 或 OPENROUTER_API_KEY(或 LLM_PROVIDER=moonshot 且 MOONSHOT_API_KEY / "
|
||||
"LLM_PROVIDER=ark 且 ARK_API_KEY)。"
|
||||
)
|
||||
|
||||
|
||||
async def run_runtime(rt: AgentRuntime):
|
||||
"""在后台跑事件循环。"""
|
||||
return asyncio.create_task(rt.serve())
|
||||
|
||||
|
||||
# ------------------------------- 四个场景 -------------------------------
|
||||
|
||||
async def scenario_1(client, model, params):
|
||||
banner("场景 1|异步工具执行:长任务运行期间即时回应插入的提问")
|
||||
rt = AgentRuntime(client, model, completion_params=params)
|
||||
serve = await run_runtime(rt)
|
||||
|
||||
# 用户下达一个耗时的日志分析任务
|
||||
await rt.submit_user_message(
|
||||
"请运行终端命令 `python analyze_logs.py`(这是耗时的日志分析),完成后给我分析结论。",
|
||||
urgency="immediate")
|
||||
await asyncio.sleep(2.2) # 任务已在后台跑
|
||||
|
||||
# 期间用户插入一个即时问题
|
||||
await rt.submit_user_message("现在几点了?") # 带问号 -> 立即回应
|
||||
|
||||
await rt.wait_until_idle()
|
||||
await rt.stop(); await serve
|
||||
|
||||
|
||||
async def scenario_2(client, model, params):
|
||||
banner("场景 2|事件队列与批量处理:非紧急指令累积,任务完成时一次性处理")
|
||||
rt = AgentRuntime(client, model, completion_params=params)
|
||||
serve = await run_runtime(rt)
|
||||
|
||||
await rt.submit_user_message(
|
||||
"请运行终端命令 `python analyze_logs.py`(耗时日志分析),完成后把分析结论告诉我。",
|
||||
urgency="immediate")
|
||||
await asyncio.sleep(1.5)
|
||||
|
||||
# 连续发两条补充性指令(无问号 -> 非紧急,进入排队缓冲)
|
||||
await rt.submit_user_message("记得最后用日语回复")
|
||||
await asyncio.sleep(0.4)
|
||||
await rt.submit_user_message("把结果整理成一个网页(HTML)")
|
||||
|
||||
await rt.wait_until_idle()
|
||||
await rt.stop(); await serve
|
||||
|
||||
|
||||
async def scenario_3(client, model, params):
|
||||
banner("场景 3|打断机制:用户'取消'立即终止执行流并取消异步工具")
|
||||
rt = AgentRuntime(client, model, completion_params=params)
|
||||
serve = await run_runtime(rt)
|
||||
|
||||
await rt.submit_user_message(
|
||||
"请运行终端命令 `python analyze_logs.py`(耗时日志分析),完成后给我结论。",
|
||||
urgency="immediate")
|
||||
await asyncio.sleep(4.0) # 等后台任务确实跑起来(跑到一半左右)
|
||||
|
||||
await rt.submit_user_message("取消") # 打断关键词 -> 立即取消
|
||||
|
||||
await rt.wait_until_idle(stable=1.0)
|
||||
await rt.stop(); await serve
|
||||
|
||||
|
||||
async def scenario_4(client, model, params):
|
||||
banner("场景 4|并行工具的取消与状态查询:三脚本竞速 + 按 50% 阈值取消 + 整合报告")
|
||||
rt = AgentRuntime(client, model, completion_params=params)
|
||||
serve = await run_runtime(rt)
|
||||
|
||||
await rt.submit_user_message(
|
||||
"同时运行这三个分析脚本:`python analyze_fast.py`、`python analyze_mid.py`、`python analyze_slow.py`。"
|
||||
"哪个脚本先完成,你就查询另外两个脚本的进度;如果某个脚本进度还没超过 50%,就取消它;"
|
||||
"其余脚本完成后,把所有已完成脚本的结果整合成一份报告给我。",
|
||||
urgency="immediate")
|
||||
|
||||
await rt.wait_until_idle(stable=1.5, timeout=60)
|
||||
await rt.stop(); await serve
|
||||
|
||||
|
||||
SCENARIOS = {1: scenario_1, 2: scenario_2, 3: scenario_3, 4: scenario_4}
|
||||
|
||||
|
||||
# ------------------------------- 子命令实现 -------------------------------
|
||||
|
||||
async def run_offline(names: list[str]) -> None:
|
||||
"""运行离线演示(无需 API key)。"""
|
||||
for name in names:
|
||||
await OFFLINE_DEMOS[name]()
|
||||
|
||||
|
||||
async def run_scenarios(which: int | None) -> None:
|
||||
"""运行 LLM 驱动的验证场景(需要 API key)。"""
|
||||
client, model, params = make_client()
|
||||
print(f"使用模型:{model}")
|
||||
todo = [which] if which else [1, 2, 3, 4]
|
||||
for i in todo:
|
||||
await SCENARIOS[i](client, model, params)
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="demo.py",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
description="实验 6-2:带并行执行、打断/取消与状态管理的异步 Agent 演示。",
|
||||
epilog=(
|
||||
"示例:\n"
|
||||
" python demo.py # 默认:依次运行三个离线演示(无需 API key)\n"
|
||||
" python demo.py parallel # 并行 vs 串行的墙钟时间对比(打印加速比)\n"
|
||||
" python demo.py interrupt # 长任务运行中被打断/取消,随后恢复\n"
|
||||
" python demo.py state # 状态检查点持久化 + 跨会话恢复并校验\n"
|
||||
" python demo.py scenarios --scenario 3 # LLM 场景 3:打断机制(需 API key)\n"
|
||||
"\n离线演示不联网、不需要任何 key;scenarios 子命令需要 OPENAI_API_KEY(或 MOONSHOT/ARK)。"
|
||||
),
|
||||
)
|
||||
sub = parser.add_subparsers(dest="command", metavar="<子命令>")
|
||||
|
||||
sub.add_parser("parallel", help="并行 vs 串行工具调用的墙钟时间对比(离线,无需 key)")
|
||||
sub.add_parser("interrupt", help="长任务运行中被打断/取消,随后系统恢复(离线,无需 key)")
|
||||
sub.add_parser("state", help="Agent 状态检查点持久化与跨会话恢复(离线,无需 key)")
|
||||
sub.add_parser("offline", help="依次运行上面三个离线演示(默认行为)")
|
||||
|
||||
ps = sub.add_parser("scenarios", help="书中四个 LLM 验证场景(需要 API key)")
|
||||
ps.add_argument("--scenario", type=int, choices=[1, 2, 3, 4],
|
||||
help="只运行指定场景(1 异步执行 / 2 批量处理 / 3 打断 / 4 并行取消);不填则全部")
|
||||
return parser
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
# 兼容旧用法:`python demo.py --scenario N` 等价于 `scenarios --scenario N`
|
||||
argv = sys.argv[1:]
|
||||
if argv and argv[0].startswith("-") and argv[0] not in ("-h", "--help"):
|
||||
argv = ["scenarios"] + argv
|
||||
|
||||
args = build_parser().parse_args(argv)
|
||||
cmd = args.command or "offline"
|
||||
|
||||
if cmd == "scenarios":
|
||||
await run_scenarios(args.scenario)
|
||||
elif cmd == "offline":
|
||||
await run_offline(["parallel", "interrupt", "state"])
|
||||
else: # parallel / interrupt / state
|
||||
await run_offline([cmd])
|
||||
|
||||
print("\n演示结束。")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,37 @@
|
||||
# 复制为 .env 后填入你的 key(demo.py 会自动加载)
|
||||
|
||||
# ===== LLM 服务(默认 openai;也支持 moonshot、dashscope/qwen/bailian、ark)=====
|
||||
LLM_PROVIDER=openai
|
||||
|
||||
# OpenAI(默认)
|
||||
OPENAI_API_KEY=your-openai-api-key
|
||||
# 可选:自定义模型 / 网关
|
||||
LLM_MODEL=gpt-5.6-luna
|
||||
# OPENAI_BASE_URL=https://your-gateway/v1
|
||||
|
||||
# Moonshot(LLM_PROVIDER=moonshot;默认模型为推理模型 kimi-k3,代码会自动用 temperature=1 且 max_tokens>=2048)
|
||||
MOONSHOT_API_KEY=your-moonshot-api-key
|
||||
# LLM_MODEL=kimi-k3
|
||||
|
||||
# Alibaba Cloud Model Studio / Bailian (Qwen)
|
||||
# DASHSCOPE_API_KEY=your-dashscope-api-key
|
||||
# LLM_PROVIDER=dashscope
|
||||
# LLM_MODEL=qwen3.7-plus
|
||||
# DASHSCOPE_BASE_URL=https://dashscope-intl.aliyuncs.com/compatible-mode/v1
|
||||
|
||||
# 火山方舟 ARK(LLM_PROVIDER=ark,LLM_MODEL 需填推理接入点 ID)
|
||||
ARK_API_KEY=xxxx
|
||||
# LLM_MODEL=ep-xxxxxxxx
|
||||
|
||||
# ===== OpenRouter 通用兜底 =====
|
||||
# 当没有配置直连的 OPENAI_API_KEY(且未使用 moonshot/ark provider)时,
|
||||
# 只要设置了 OPENROUTER_API_KEY,demo.py 会自动改走 OpenRouter:
|
||||
# base_url=https://openrouter.ai/api/v1,并把模型名映射成 provider/model 形式
|
||||
# (gpt-* -> openai/…、claude-* -> anthropic/claude-opus-4.8、含 "/" 的原样透传)。
|
||||
# 也可显式 LLM_PROVIDER=openrouter 强制使用。
|
||||
# OPENROUTER_API_KEY=your-openrouter-api-key
|
||||
# LLM_MODEL=openai/gpt-5.6-luna
|
||||
|
||||
# ===== 时间轴加速(可选)=====
|
||||
# 1 个"模拟秒"对应的真实秒数,默认 0.4(2.5 倍速)。数值越小演示越快。
|
||||
FLUX_TICK_REAL=0.4
|
||||
@@ -0,0 +1,91 @@
|
||||
"""事件模型(对应设计文档中的 Event / Trajectory 概念)。
|
||||
|
||||
Flux 把 Agent 的一切经历都抽象成"事件",按时间顺序追加到轨迹(trajectory)里。
|
||||
本文件定义事件类型、事件对象,以及"事件紧急度"的判定逻辑——这是实验 6-2 里
|
||||
"批量处理 vs 立即打断"两种处理机制的分类依据。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class EventType:
|
||||
"""事件类型常量(对应设计文档第 2 节 Inputs / Interrupts / Thinking / Actions)。"""
|
||||
|
||||
USER_INPUT = "user.input" # 用户输入(非紧急,走"排队处理")
|
||||
USER_INTERRUPT = "user.interrupt" # 用户打断(紧急,走"取消式处理")
|
||||
AGENT_OUTPUT = "agent.output" # Agent 面向用户的最终回复
|
||||
AGENT_TOOL_CALL = "agent.tool_call" # Agent 发起的工具调用(Action)
|
||||
TOOL_RESULT = "tool.result" # 工具返回结果(同步工具 / 异步占位符)
|
||||
ASYNC_RESULT = "async.result" # 异步工具真正完成后注入的新事件
|
||||
SYSTEM_NOTE = "system.note" # 框架注入的系统提示(如取消回执)
|
||||
|
||||
|
||||
class Urgency:
|
||||
"""事件紧急度:决定采用哪种事件处理机制。"""
|
||||
|
||||
INTERRUPT = "interrupt" # 取消式处理:立刻打断当前执行并取消异步工具
|
||||
IMMEDIATE = "immediate" # 立即处理:不打断后台异步任务,但马上回应(如用户提问)
|
||||
DEFERRED = "deferred" # 排队处理:累积到 pending 队列,任务完成时批量追加
|
||||
|
||||
|
||||
# 打断类关键词:命中即视为紧急打断
|
||||
_INTERRUPT_KEYWORDS = ["取消", "停止", "中止", "打住", "别做了", "stop", "cancel", "abort"]
|
||||
|
||||
# 疑问类信号:命中即视为需要"立即回应"(而不是排队)
|
||||
_QUESTION_MARKS = ("?", "?")
|
||||
_QUESTION_KEYWORDS = ["几点", "多少", "怎么", "如何", "为什么", "是不是", "有没有",
|
||||
"吗", "呢", "what", "when", "how", "why", "which"]
|
||||
|
||||
|
||||
def classify_urgency(text: str) -> str:
|
||||
"""根据用户消息内容判定紧急度。
|
||||
|
||||
规则(简单、可解释,便于书中讲清楚):
|
||||
1. 含打断关键词(取消/停止/stop...) -> INTERRUPT(紧急,取消式处理)
|
||||
2. 是一个提问(带问号或疑问词) -> IMMEDIATE(立即回应,但不打断后台任务)
|
||||
3. 其它(补充性指令,如"用日语回复")-> DEFERRED(排队,批量处理)
|
||||
"""
|
||||
low = text.lower()
|
||||
if any(kw in text or kw in low for kw in _INTERRUPT_KEYWORDS):
|
||||
return Urgency.INTERRUPT
|
||||
if text.strip().endswith(_QUESTION_MARKS) or any(kw in text or kw in low for kw in _QUESTION_KEYWORDS):
|
||||
return Urgency.IMMEDIATE
|
||||
return Urgency.DEFERRED
|
||||
|
||||
|
||||
@dataclass
|
||||
class Event:
|
||||
"""一条轨迹事件。
|
||||
|
||||
message 字段保存"可直接喂给 LLM 的 OpenAI 消息字典"(保证上下文的高保真回放);
|
||||
没有 message 的事件(若有)只用于日志。
|
||||
"""
|
||||
|
||||
type: str
|
||||
message: Optional[dict] = None # OpenAI chat 格式消息,供构建 LLM 上下文
|
||||
label: str = "" # 人类可读的日志标签
|
||||
task_id: Optional[str] = None # 关联的异步任务 ID(若有)
|
||||
urgency: Optional[str] = None # 仅用户输入事件会带
|
||||
ts: float = field(default_factory=time.time)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""序列化为纯 JSON 可写的字典(用于状态检查点持久化)。"""
|
||||
return {
|
||||
"type": self.type, "message": self.message, "label": self.label,
|
||||
"task_id": self.task_id, "urgency": self.urgency, "ts": self.ts,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict) -> "Event":
|
||||
"""从检查点字典还原事件对象。"""
|
||||
raw_ts = d.get("ts")
|
||||
return cls(
|
||||
type=d["type"], message=d.get("message"),
|
||||
label=d.get("label") or "",
|
||||
task_id=d.get("task_id"), urgency=d.get("urgency"),
|
||||
ts=raw_ts if raw_ts is not None else time.time(),
|
||||
)
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"experiment": "6-2",
|
||||
"title": "Real subprocess async execution, queueing, interruption, and progress cancellation",
|
||||
"authority": "book/chapter4.md:579",
|
||||
"execution": {
|
||||
"mode": "allowlisted asyncio subprocesses",
|
||||
"shell": false,
|
||||
"worker": "analysis_worker.py",
|
||||
"input": "book/chapter4.md",
|
||||
"progress_source": "child stdout",
|
||||
"result_source": "child-computed file metrics"
|
||||
},
|
||||
"scenarios": [
|
||||
"long command plus immediate current-time response before completion",
|
||||
"two deferred instructions batch on completion and produce Japanese HTML",
|
||||
"user cancellation terminates the child process and runtime recovers",
|
||||
"3/2/1 percent jobs; query remaining jobs once; cancel only progress at or below 50 percent"
|
||||
],
|
||||
"acceptance": {
|
||||
"long_job_at_least_three_seconds": true,
|
||||
"placeholder_return_is_nonblocking": true,
|
||||
"all_terminal_jobs_are_real_subprocesses": true,
|
||||
"cancelled_jobs_have_os_return_codes": true,
|
||||
"completed_jobs_have_stdout_and_input_hashes": true,
|
||||
"all_artifacts_are_hash_manifested": true,
|
||||
"no_simulated_terminal_result_can_pass": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
openai>=1.30.0
|
||||
python-dotenv>=1.0.0
|
||||
@@ -0,0 +1,423 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run all four Experiment 6-2 scenarios with real child processes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import tasks
|
||||
from tasks import TaskManager, TaskState
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
PROTOCOL_PATH = HERE / "experiment_protocol.json"
|
||||
VALIDATION_ROOT = HERE / "validation" / "experiment_6_2"
|
||||
UTC = timezone.utc
|
||||
|
||||
|
||||
def canonical_json(value: Any) -> str:
|
||||
return json.dumps(value, ensure_ascii=False, sort_keys=True,
|
||||
separators=(",", ":"), default=str)
|
||||
|
||||
|
||||
def sha256(value: bytes | str) -> str:
|
||||
if isinstance(value, str):
|
||||
value = value.encode()
|
||||
return hashlib.sha256(value).hexdigest()
|
||||
|
||||
|
||||
def write_json(path: Path, value: Any) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(value, ensure_ascii=False, indent=2, default=str) + "\n",
|
||||
encoding="utf-8")
|
||||
|
||||
|
||||
def task_receipt(state: TaskState) -> dict[str, Any]:
|
||||
result: Any = state.result
|
||||
try:
|
||||
result = json.loads(state.result) if state.result else None
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return {
|
||||
"task_id": state.task_id, "command": state.command,
|
||||
"status": state.status, "progress": state.progress,
|
||||
"pid": state.pid, "returncode": state.returncode,
|
||||
"started_at": state.started_at, "completed_at": state.completed_at,
|
||||
"elapsed_seconds": (
|
||||
round(state.completed_at - state.started_at, 3)
|
||||
if state.started_at and state.completed_at else None
|
||||
),
|
||||
"stdout_sha256": state.stdout_sha256,
|
||||
"stderr_tail": state.stderr_tail,
|
||||
"result": result,
|
||||
"executable": state.executable_receipt,
|
||||
}
|
||||
|
||||
|
||||
class ReceiptLog:
|
||||
def __init__(self):
|
||||
self.started = time.perf_counter()
|
||||
self.events: list[dict[str, Any]] = []
|
||||
|
||||
def __call__(self, source: str, text: str) -> None:
|
||||
self.events.append({"elapsed_seconds": round(time.perf_counter() - self.started, 3),
|
||||
"source": source, "text": text})
|
||||
|
||||
def add(self, source: str, event: str, **details: Any) -> float:
|
||||
elapsed = round(time.perf_counter() - self.started, 3)
|
||||
self.events.append({"elapsed_seconds": elapsed, "source": source,
|
||||
"event": event, **details})
|
||||
return elapsed
|
||||
|
||||
|
||||
async def _await_cancelled(state: TaskState) -> None:
|
||||
if state._task is None:
|
||||
return
|
||||
try:
|
||||
await state._task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
|
||||
async def scenario_1() -> dict[str, Any]:
|
||||
log = ReceiptLog()
|
||||
completed_at: dict[str, float] = {}
|
||||
|
||||
async def complete(state: TaskState) -> None:
|
||||
completed_at[state.task_id] = log.add("SYSTEM", "async_result_injected",
|
||||
task_id=state.task_id)
|
||||
|
||||
manager = TaskManager(complete, log)
|
||||
before = time.perf_counter()
|
||||
state = manager.start("python analyze_logs.py")
|
||||
placeholder_latency = round(time.perf_counter() - before, 6)
|
||||
placeholder_at = log.add("TOOL", "placeholder_returned", task_id=state.task_id,
|
||||
placeholder_latency_seconds=placeholder_latency)
|
||||
await asyncio.sleep(0.5)
|
||||
time_answer = datetime.now().astimezone().isoformat(timespec="seconds")
|
||||
time_answer_at = log.add("AGENT", "immediate_time_answer", answer=time_answer,
|
||||
task_still_running=state.status == "running")
|
||||
assert state._task is not None
|
||||
await state._task
|
||||
return {
|
||||
"id": "async_command_and_immediate_question",
|
||||
"placeholder_latency_seconds": placeholder_latency,
|
||||
"placeholder_at": placeholder_at, "time_answer_at": time_answer_at,
|
||||
"completion_event_at": completed_at.get(state.task_id),
|
||||
"time_answer": time_answer, "events": log.events,
|
||||
"tasks": [task_receipt(state)],
|
||||
}
|
||||
|
||||
|
||||
async def scenario_2(campaign_dir: Path) -> dict[str, Any]:
|
||||
log = ReceiptLog()
|
||||
pending: list[dict[str, Any]] = []
|
||||
batch: list[dict[str, Any]] = []
|
||||
|
||||
async def complete(state: TaskState) -> None:
|
||||
batch.append({"type": "async.result", "task_id": state.task_id,
|
||||
"result_sha256": sha256(state.result)})
|
||||
batch.extend(pending)
|
||||
log.add("SYSTEM", "batch_appended", event_count=len(batch),
|
||||
deferred_count=len(pending))
|
||||
|
||||
manager = TaskManager(complete, log)
|
||||
state = manager.start("python analyze_logs.py")
|
||||
log.add("TOOL", "placeholder_returned", task_id=state.task_id)
|
||||
await asyncio.sleep(0.5)
|
||||
pending.append({"type": "user.input", "instruction": "記得最後用日語回覆"})
|
||||
first_at = log.add("QUEUE", "deferred_instruction", instruction="japanese")
|
||||
await asyncio.sleep(0.2)
|
||||
pending.append({"type": "user.input", "instruction": "結果をHTMLウェブページに整理"})
|
||||
second_at = log.add("QUEUE", "deferred_instruction", instruction="html")
|
||||
assert state._task is not None
|
||||
await state._task
|
||||
metrics = json.loads(state.result)
|
||||
html = (
|
||||
"<!DOCTYPE html><html lang=\"ja\"><meta charset=\"utf-8\">"
|
||||
"<title>非同期分析レポート</title><body><h1>分析結果</h1>"
|
||||
f"<p>対象ファイルは {metrics['lines']} 行、{metrics['bytes']} バイトです。</p>"
|
||||
f"<p>非同期に関する言及は {metrics['async_mentions']} 件でした。</p>"
|
||||
"</body></html>\n"
|
||||
)
|
||||
output = campaign_dir / "artifacts" / "scenario_2_report.html"
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
output.write_text(html, encoding="utf-8")
|
||||
return {
|
||||
"id": "queued_batch_to_japanese_html",
|
||||
"instruction_times": [first_at, second_at],
|
||||
"batch": batch, "events": log.events,
|
||||
"artifact": {"path": str(output), "bytes": output.stat().st_size,
|
||||
"sha256": sha256(output.read_bytes()),
|
||||
"doctype": html.startswith("<!DOCTYPE html>"),
|
||||
"lang_ja": 'lang="ja"' in html,
|
||||
"has_japanese": bool(re.search(r"[ぁ-んァ-ン一-龯]", html))},
|
||||
"tasks": [task_receipt(state)],
|
||||
}
|
||||
|
||||
|
||||
async def scenario_3() -> dict[str, Any]:
|
||||
log = ReceiptLog()
|
||||
completed: list[str] = []
|
||||
|
||||
async def complete(state: TaskState) -> None:
|
||||
completed.append(state.task_id)
|
||||
log.add("SYSTEM", "async_result_injected", task_id=state.task_id)
|
||||
|
||||
manager = TaskManager(complete, log)
|
||||
interrupted = manager.start("python analyze_logs.py")
|
||||
await asyncio.sleep(0.8)
|
||||
progress_at_interrupt = interrupted.progress
|
||||
interrupt_at = log.add("USER", "user.interrupt", text="取消",
|
||||
task_id=interrupted.task_id,
|
||||
progress=progress_at_interrupt)
|
||||
cancel_started = time.perf_counter()
|
||||
cancelled = manager.cancel_all()
|
||||
await _await_cancelled(interrupted)
|
||||
cancel_latency = round(time.perf_counter() - cancel_started, 3)
|
||||
cancel_receipt_at = log.add("SYSTEM", "process_cancelled", task_ids=cancelled,
|
||||
cancel_latency_seconds=cancel_latency,
|
||||
returncode=interrupted.returncode)
|
||||
recovery = manager.start("python re_run_summary.py")
|
||||
recovery_at = log.add("SYSTEM", "runtime_recovered", task_id=recovery.task_id)
|
||||
assert recovery._task is not None
|
||||
await recovery._task
|
||||
return {
|
||||
"id": "interrupt_terminates_and_recovers",
|
||||
"interrupt_at": interrupt_at, "cancel_receipt_at": cancel_receipt_at,
|
||||
"cancel_latency_seconds": cancel_latency,
|
||||
"recovery_at": recovery_at, "completed_callbacks": completed,
|
||||
"events": log.events,
|
||||
"tasks": [task_receipt(interrupted), task_receipt(recovery)],
|
||||
}
|
||||
|
||||
|
||||
async def scenario_4(campaign_dir: Path) -> dict[str, Any]:
|
||||
log = ReceiptLog()
|
||||
completion_queue: asyncio.Queue[TaskState] = asyncio.Queue()
|
||||
|
||||
async def complete(state: TaskState) -> None:
|
||||
log.add("SYSTEM", "async_result_injected", task_id=state.task_id)
|
||||
await completion_queue.put(state)
|
||||
|
||||
manager = TaskManager(complete, log)
|
||||
states = [manager.start(command) for command in (
|
||||
"python analyze_fast.py", "python analyze_mid.py", "python analyze_slow.py"
|
||||
)]
|
||||
first = await asyncio.wait_for(completion_queue.get(), timeout=30)
|
||||
query_receipts = []
|
||||
cancelled_ids = []
|
||||
for state in states:
|
||||
if state.task_id == first.task_id or state.status != "running":
|
||||
continue
|
||||
query_receipts.append({"task_id": state.task_id, "status": state.status,
|
||||
"progress": state.progress,
|
||||
"queried_at": log.add("TOOL", "query_task",
|
||||
task_id=state.task_id,
|
||||
progress=state.progress)})
|
||||
if state.progress <= 50:
|
||||
if manager.cancel(state.task_id):
|
||||
cancelled_ids.append(state.task_id)
|
||||
log.add("TOOL", "cancel_task", task_id=state.task_id,
|
||||
progress=state.progress)
|
||||
for state in states:
|
||||
await _await_cancelled(state)
|
||||
report_rows = [task_receipt(state) for state in states]
|
||||
report = {
|
||||
"first_completed": first.task_id, "query_receipts": query_receipts,
|
||||
"cancelled_ids": cancelled_ids,
|
||||
"completed_results": {row["task_id"]: row["result"] for row in report_rows
|
||||
if row["status"] == "completed"},
|
||||
}
|
||||
output = campaign_dir / "artifacts" / "scenario_4_report.json"
|
||||
write_json(output, report)
|
||||
return {
|
||||
"id": "parallel_progress_threshold_cancellation",
|
||||
**report, "events": log.events, "tasks": report_rows,
|
||||
"artifact": {"path": str(output), "bytes": output.stat().st_size,
|
||||
"sha256": sha256(output.read_bytes())},
|
||||
}
|
||||
|
||||
|
||||
def real_task(receipt: dict[str, Any]) -> bool:
|
||||
executable = receipt.get("executable", {})
|
||||
common = (
|
||||
receipt.get("pid") is not None
|
||||
and executable.get("mode") == "real_subprocess"
|
||||
and executable.get("shell") is False
|
||||
and len(executable.get("worker_sha256", "")) == 64
|
||||
and len(executable.get("input_sha256", "")) == 64
|
||||
and len(executable.get("argv_sha256", "")) == 64
|
||||
)
|
||||
if receipt.get("status") == "completed":
|
||||
return common and receipt.get("returncode") == 0 \
|
||||
and len(receipt.get("stdout_sha256", "")) == 64 \
|
||||
and isinstance(receipt.get("result"), dict)
|
||||
if receipt.get("status") == "cancelled":
|
||||
return common and receipt.get("returncode") not in {None, 0} \
|
||||
and executable.get("cancelled") is True
|
||||
return False
|
||||
|
||||
|
||||
def derive_acceptance(scenarios: list[dict[str, Any]], protocol: dict[str, Any]) -> dict[str, Any]:
|
||||
# Explicit mapping from protocol acceptance keys to the gate keys that
|
||||
# enforce them. This prevents silent drift: adding a key to the protocol's
|
||||
# acceptance block without a corresponding gate entry raises an assertion
|
||||
# at run time, and removing a gate key leaves a dangling reference that
|
||||
# the coverage check also catches.
|
||||
PROTOCOL_TO_GATE: dict[str, str | tuple[str, ...]] = {
|
||||
"long_job_at_least_three_seconds": "scenario_1_nonblocking_and_immediate_response",
|
||||
"placeholder_return_is_nonblocking": "scenario_1_nonblocking_and_immediate_response",
|
||||
"all_terminal_jobs_are_real_subprocesses": "real_subprocess_receipts_only",
|
||||
"cancelled_jobs_have_os_return_codes": "scenario_3_os_process_cancelled_then_recovered",
|
||||
"completed_jobs_have_stdout_and_input_hashes": "real_subprocess_receipts_only",
|
||||
"all_artifacts_are_hash_manifested": (
|
||||
"scenario_2_japanese_html_artifact",
|
||||
"scenario_4_integrated_report_hashed",
|
||||
),
|
||||
"no_simulated_terminal_result_can_pass": "real_subprocess_receipts_only",
|
||||
}
|
||||
protocol_acceptance = protocol.get("acceptance", {})
|
||||
missing = set(protocol_acceptance) - set(PROTOCOL_TO_GATE)
|
||||
assert not missing, (
|
||||
f"protocol acceptance keys not covered by PROTOCOL_TO_GATE: {missing}"
|
||||
)
|
||||
by_id = {row["id"]: row for row in scenarios}
|
||||
one = by_id.get("async_command_and_immediate_question", {})
|
||||
two = by_id.get("queued_batch_to_japanese_html", {})
|
||||
three = by_id.get("interrupt_terminates_and_recovers", {})
|
||||
four = by_id.get("parallel_progress_threshold_cancellation", {})
|
||||
all_tasks = [task for scenario in scenarios for task in scenario.get("tasks", [])]
|
||||
q = four.get("query_receipts", [])
|
||||
q_by_id = {row["task_id"]: row for row in q}
|
||||
tasks4 = {row["task_id"]: row for row in four.get("tasks", [])}
|
||||
gates = {
|
||||
"exact_four_scenarios": len(scenarios) == 4 and len(by_id) == 4,
|
||||
"real_subprocess_receipts_only": bool(all_tasks) and all(real_task(row) for row in all_tasks),
|
||||
"scenario_1_nonblocking_and_immediate_response": (
|
||||
one.get("placeholder_latency_seconds", 1) < 0.1
|
||||
and one.get("time_answer_at", 999) < one.get("completion_event_at", -1)
|
||||
and one.get("tasks", [{}])[0].get("elapsed_seconds", 0) >= 3
|
||||
and one.get("tasks", [{}])[0].get("status") == "completed"
|
||||
),
|
||||
"scenario_2_deferred_events_batched_once": (
|
||||
len(two.get("batch", [])) == 3
|
||||
and two.get("batch", [{}])[0].get("type") == "async.result"
|
||||
and [row.get("type") for row in two.get("batch", [])[1:]]
|
||||
== ["user.input", "user.input"]
|
||||
and len([event for event in two.get("events", [])
|
||||
if event.get("event") == "batch_appended"]) == 1
|
||||
),
|
||||
"scenario_2_japanese_html_artifact": all([
|
||||
two.get("artifact", {}).get("doctype"), two.get("artifact", {}).get("lang_ja"),
|
||||
two.get("artifact", {}).get("has_japanese"),
|
||||
two.get("artifact", {}).get("bytes", 0) > 100,
|
||||
len(two.get("artifact", {}).get("sha256", "")) == 64,
|
||||
]),
|
||||
"scenario_3_os_process_cancelled_then_recovered": (
|
||||
len(three.get("tasks", [])) == 2
|
||||
and three["tasks"][0].get("status") == "cancelled"
|
||||
and three["tasks"][0].get("progress", 100) < 100
|
||||
and three["tasks"][0].get("returncode") not in {None, 0}
|
||||
and three["tasks"][1].get("status") == "completed"
|
||||
and three.get("cancel_receipt_at", 0) >= three.get("interrupt_at", 999)
|
||||
and three.get("recovery_at", 0) >= three.get("cancel_receipt_at", 999)
|
||||
),
|
||||
"scenario_4_exact_rates_and_fast_first": (
|
||||
four.get("first_completed") == "T1"
|
||||
and [row.get("executable", {}).get("rate_percent_per_logical_second")
|
||||
for row in four.get("tasks", [])] == [3.0, 2.0, 1.0]
|
||||
),
|
||||
"scenario_4_query_once_and_cancel_only_under_threshold": (
|
||||
len(q) == len(q_by_id) == 2
|
||||
and set(q_by_id) == {"T2", "T3"}
|
||||
and q_by_id["T2"]["progress"] > 50
|
||||
and q_by_id["T3"]["progress"] <= 50
|
||||
and four.get("cancelled_ids") == ["T3"]
|
||||
and tasks4.get("T2", {}).get("status") == "completed"
|
||||
and tasks4.get("T3", {}).get("status") == "cancelled"
|
||||
),
|
||||
"scenario_4_integrated_report_hashed": (
|
||||
set(four.get("completed_results", {})) == {"T1", "T2"}
|
||||
and four.get("artifact", {}).get("bytes", 0) > 100
|
||||
and len(four.get("artifact", {}).get("sha256", "")) == 64
|
||||
),
|
||||
}
|
||||
# Verify that every referenced gate key actually exists.
|
||||
referenced_gate_keys = set()
|
||||
for gate_spec in PROTOCOL_TO_GATE.values():
|
||||
if isinstance(gate_spec, str):
|
||||
referenced_gate_keys.add(gate_spec)
|
||||
else:
|
||||
referenced_gate_keys.update(gate_spec)
|
||||
dangling = referenced_gate_keys - set(gates)
|
||||
assert not dangling, (
|
||||
f"PROTOCOL_TO_GATE references gate keys that do not exist: {dangling}"
|
||||
)
|
||||
# Compute per-protocol-key coverage so an auditor can mechanically verify
|
||||
# that every acceptance declaration is enforced by at least one gate.
|
||||
protocol_coverage: dict[str, Any] = {}
|
||||
for proto_key, gate_spec in PROTOCOL_TO_GATE.items():
|
||||
gate_keys = (gate_spec,) if isinstance(gate_spec, str) else gate_spec
|
||||
protocol_coverage[proto_key] = {
|
||||
"enforced_by": list(gate_keys),
|
||||
"all_gates_passed": all(gates.get(gk, False) for gk in gate_keys),
|
||||
}
|
||||
return {"status": "passed" if all(gates.values()) else "failed", "gates": gates,
|
||||
"protocol_coverage": protocol_coverage,
|
||||
"protocol_sha256": sha256(canonical_json(protocol))}
|
||||
|
||||
|
||||
def manifest(campaign_dir: Path) -> dict[str, Any]:
|
||||
files = []
|
||||
for path in sorted(campaign_dir.rglob("*")):
|
||||
if path.is_file() and path.name != "manifest.json":
|
||||
data = path.read_bytes()
|
||||
files.append({"path": str(path.relative_to(campaign_dir)),
|
||||
"bytes": len(data), "sha256": sha256(data)})
|
||||
return {"generated_at": datetime.now(UTC).isoformat(), "files": files}
|
||||
|
||||
|
||||
async def run(campaign_id: str | None, tick_real: float) -> Path:
|
||||
if tick_real <= 0:
|
||||
raise ValueError("tick-real must be positive")
|
||||
tasks.TICK_REAL = tick_real
|
||||
protocol = json.loads(PROTOCOL_PATH.read_text(encoding="utf-8"))
|
||||
campaign_id = campaign_id or datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
|
||||
campaign_dir = VALIDATION_ROOT / campaign_id
|
||||
campaign_dir.mkdir(parents=True, exist_ok=False)
|
||||
write_json(campaign_dir / "protocol.json", protocol)
|
||||
started = time.perf_counter()
|
||||
scenarios = [await scenario_1(), await scenario_2(campaign_dir),
|
||||
await scenario_3(), await scenario_4(campaign_dir)]
|
||||
for scenario in scenarios:
|
||||
write_json(campaign_dir / "scenarios" / f"{scenario['id']}.json", scenario)
|
||||
acceptance = derive_acceptance(scenarios, protocol)
|
||||
summary = {"experiment": "6-2", "campaign_id": campaign_id,
|
||||
"generated_at": datetime.now(UTC).isoformat(),
|
||||
"tick_real_seconds": tick_real,
|
||||
"elapsed_seconds": round(time.perf_counter() - started, 3),
|
||||
"scenario_status": {row["id"]: "recorded" for row in scenarios},
|
||||
"acceptance": acceptance, "status": acceptance["status"]}
|
||||
write_json(campaign_dir / "summary.json", summary)
|
||||
write_json(campaign_dir / "manifest.json", manifest(campaign_dir))
|
||||
return campaign_dir
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--campaign-id")
|
||||
parser.add_argument("--tick-real", type=float, default=0.15)
|
||||
args = parser.parse_args()
|
||||
print(asyncio.run(run(args.campaign_id, args.tick_real)))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,406 @@
|
||||
"""Flux 异步 Agent 运行时(实验 6-2 核心)。
|
||||
|
||||
实现设计文档第 5 节的事件处理循环,重点覆盖实验 6-2 的四个能力:
|
||||
1. 异步工具执行:run_terminal_command 立即返回占位符,任务在后台跑。
|
||||
2. 事件队列与批量处理:非紧急事件进 pending,异步结果到达时一次性批量追加。
|
||||
3. 打断机制:用户"取消/停止"立即取消当前 turn + 所有异步工具,并留痕。
|
||||
4. 并行工具的取消与状态查询:query_task / cancel_task 按 ID 操作;
|
||||
异步完成后以"新事件"把真实结果注入对话。
|
||||
|
||||
架构(三个协程协作,全部基于 asyncio 单线程):
|
||||
- inbox 队列:所有进来的事件(用户输入、打断、异步完成通知)先入 inbox。
|
||||
- _dispatcher:从 inbox 取事件 -> 判定紧急度 -> 分流(立即处理 / 排队 / 打断)。
|
||||
- _worker :从 work 队列取"事件批次" -> 追加到轨迹 -> 跑一轮 LLM(run_llm_turn)。
|
||||
每一轮 LLM 作为可取消的子任务(turn_task),打断时直接 cancel 它。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from events import Event, EventType, Urgency, classify_urgency
|
||||
from tasks import TaskManager, TaskState
|
||||
|
||||
# ------------------------- LLM 工具定义(function calling) -------------------------
|
||||
|
||||
TOOL_SCHEMAS = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "run_terminal_command",
|
||||
"description": ("异步执行一个受限的真实日志分析子进程。调用后立即返回 task_id,"
|
||||
"不会阻塞;进度来自子进程 stdout。自然完成后,真实返回码、输出哈希和"
|
||||
"文件分析指标会作为新的系统事件出现。取消会终止对应 OS 进程。"),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {"type": "string", "description": "要执行的终端命令,如 `python analyze_logs.py`"},
|
||||
},
|
||||
"required": ["command"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_current_time",
|
||||
"description": "立即返回当前时间。用于回答用户'现在几点了'之类的即时问题。",
|
||||
"parameters": {"type": "object", "properties": {}},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "query_task",
|
||||
"description": "查询某个后台异步任务的当前进度与状态。",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"task_id": {"type": "string", "description": "任务 ID,如 T1"}},
|
||||
"required": ["task_id"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "cancel_task",
|
||||
"description": "按 task_id 取消一个正在运行的后台异步任务。",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"task_id": {"type": "string", "description": "任务 ID,如 T1"}},
|
||||
"required": ["task_id"],
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
SYSTEM_PROMPT = """你是一个异步 Agent(基于 Flux 框架)。你可以调用工具来完成任务。
|
||||
|
||||
关键行为准则:
|
||||
1. run_terminal_command 是【异步】的:调用后命令在后台运行并立即返回 task_id。
|
||||
你应当简要告知用户"任务已在后台启动",然后【结束本轮回复,不要空等结果】。
|
||||
2. 当你看到形如 "[系统事件|异步任务完成] task_id=... 结果:..." 的消息时,
|
||||
说明后台任务真的完成了,这时再基于结果给出分析/整合结论。
|
||||
3. 如果用户在后台任务运行期间提出简短问题(例如"现在几点了?"),
|
||||
立即用对应工具(如 get_current_time)回答,【不要等待】后台任务。
|
||||
4. 你可以用 query_task 查询任意后台任务进度,用 cancel_task 按 ID 取消任务。
|
||||
5. 收到 "[用户打断]" 时,立即停止当前工作并简短确认已停止。
|
||||
6. 严格按用户给出的计划执行(例如"谁先完成就查其余进度,未过 50% 就取消")。
|
||||
注意:只取消【进度未超过 50%】的任务;进度已超过 50% 的任务应【保留并等待其完成】,不要取消它。
|
||||
每个还在运行的任务只需查询一次进度即可做出取消/保留决定,不要反复查询。
|
||||
7. 回答简洁、用中文,除非用户明确要求其它语言或格式。
|
||||
"""
|
||||
|
||||
MAX_STEPS = 8 # 单轮内最多的工具调用往返次数(防止死循环)
|
||||
|
||||
# 日志配色(各来源一种颜色),供 runtime 与离线演示脚本共用。
|
||||
_LOG_COLORS = {
|
||||
"USER": "\033[96m", "AGENT": "\033[92m", "TOOL": "\033[93m",
|
||||
"TASK": "\033[95m", "SYSTEM": "\033[90m", "TRAJ": "\033[94m",
|
||||
"STATE": "\033[95m",
|
||||
}
|
||||
|
||||
|
||||
def format_log(t0: float, source: str, text: str) -> str:
|
||||
"""把一条日志渲染成「[相对秒] 来源 | 文本」的彩色字符串。"""
|
||||
color = _LOG_COLORS.get(source, "")
|
||||
reset = "\033[0m" if color else ""
|
||||
return f"[{time.time() - t0:6.2f}s] {color}{source:6}{reset} | {text}"
|
||||
|
||||
|
||||
class AgentRuntime:
|
||||
def __init__(self, client, model: str, start_time: Optional[float] = None,
|
||||
completion_params: Optional[dict] = None):
|
||||
self.client = client
|
||||
self.model = model
|
||||
# 传给 chat.completions.create 的采样参数。默认 temperature=0.2 适合 gpt-5.6-luna;
|
||||
# 推理模型(如 Moonshot kimi-k3)需要 temperature=1 且 max_tokens>=2048,由 make_client 传入。
|
||||
self.completion_params = completion_params or {"temperature": 0.2}
|
||||
self._t0 = start_time or time.time()
|
||||
|
||||
self.trajectory: list[Event] = [] # 轨迹(工作记忆)
|
||||
self.inbox: asyncio.Queue = asyncio.Queue() # 所有进来的原始事件
|
||||
self.work: asyncio.Queue = asyncio.Queue() # 待处理的事件批次
|
||||
self.pending: list[Event] = [] # 非紧急事件的排队缓冲
|
||||
|
||||
self.tasks = TaskManager(on_complete=self._on_task_complete, log=self.log)
|
||||
self.turn_task: Optional[asyncio.Task] = None
|
||||
self.running = True
|
||||
self._STOP = object()
|
||||
|
||||
# ------------------------------- 日志 -------------------------------
|
||||
|
||||
def log(self, source: str, text: str) -> None:
|
||||
print(format_log(self._t0, source, text), flush=True)
|
||||
|
||||
def _append(self, event: Event) -> None:
|
||||
"""把事件追加到轨迹,并打印轨迹留痕。"""
|
||||
self.trajectory.append(event)
|
||||
self.log("TRAJ", f"+ {event.type:18} {event.label}")
|
||||
|
||||
def build_messages(self) -> list[dict]:
|
||||
"""把轨迹渲染成 OpenAI chat 消息列表。"""
|
||||
msgs = [{"role": "system", "content": SYSTEM_PROMPT}]
|
||||
for e in self.trajectory:
|
||||
if e.message:
|
||||
msgs.append(e.message)
|
||||
return msgs
|
||||
|
||||
# ------------------------- 对外接口:提交事件 -------------------------
|
||||
|
||||
async def submit_user_message(self, text: str, urgency: Optional[str] = None) -> None:
|
||||
"""提交一条用户消息(demo 用它模拟用户输入)。"""
|
||||
u = urgency or classify_urgency(text)
|
||||
if u == Urgency.INTERRUPT:
|
||||
ev = Event(EventType.USER_INTERRUPT, urgency=u,
|
||||
message={"role": "user", "content": f"[用户打断] {text}"},
|
||||
label=f"用户打断:{text}")
|
||||
else:
|
||||
ev = Event(EventType.USER_INPUT, urgency=u,
|
||||
message={"role": "user", "content": text},
|
||||
label=f"用户消息({u}):{text}")
|
||||
self.log("USER", f"({u}) {text}")
|
||||
await self.inbox.put(ev)
|
||||
|
||||
async def _on_task_complete(self, state: TaskState) -> None:
|
||||
"""异步任务自然完成 -> 把真实结果作为【新事件】注入 inbox。"""
|
||||
ev = Event(
|
||||
EventType.ASYNC_RESULT, task_id=state.task_id,
|
||||
message={"role": "user",
|
||||
"content": (f"[系统事件|异步任务完成] task_id={state.task_id} "
|
||||
f"命令=`{state.command}` 结果:{state.result}")},
|
||||
label=f"异步完成 {state.task_id}",
|
||||
)
|
||||
await self.inbox.put(ev)
|
||||
|
||||
# ------------------------------- 主循环 -------------------------------
|
||||
|
||||
async def serve(self) -> None:
|
||||
dispatcher = asyncio.create_task(self._dispatcher())
|
||||
worker = asyncio.create_task(self._worker())
|
||||
await asyncio.gather(dispatcher, worker)
|
||||
|
||||
def _is_idle(self) -> bool:
|
||||
return (not self.tasks.any_running()
|
||||
and self.work.empty()
|
||||
and self.inbox.empty()
|
||||
and (self.turn_task is None or self.turn_task.done()))
|
||||
|
||||
def _drain_pending(self) -> list[Event]:
|
||||
drained, self.pending = self.pending, []
|
||||
return drained
|
||||
|
||||
async def _dispatcher(self) -> None:
|
||||
"""事件分流:实现设计文档 5.1 的两种处理机制。"""
|
||||
while self.running:
|
||||
ev = await self.inbox.get()
|
||||
if ev is self._STOP:
|
||||
await self.work.put(self._STOP)
|
||||
break
|
||||
|
||||
if ev.type == EventType.USER_INTERRUPT:
|
||||
# —— 取消式处理:立刻打断当前 turn + 取消所有异步工具 ——
|
||||
await self._handle_interrupt(ev)
|
||||
|
||||
elif ev.type == EventType.ASYNC_RESULT:
|
||||
# —— 异步结果到达:批量把 pending 一并追加,再触发 LLM ——
|
||||
batch = [ev] + self._drain_pending()
|
||||
if len(batch) > 1:
|
||||
self.log("SYSTEM", f"异步结果到达,批量处理 {len(batch)-1} 条积压的非紧急事件")
|
||||
await self.work.put(batch)
|
||||
|
||||
elif ev.type == EventType.USER_INPUT:
|
||||
if ev.urgency == Urgency.IMMEDIATE:
|
||||
# 立即处理(如用户提问),不打断后台异步任务
|
||||
await self.work.put([ev])
|
||||
elif self._is_idle():
|
||||
# 空闲时,普通指令也直接处理(例如一开始下达的任务)
|
||||
await self.work.put([ev])
|
||||
else:
|
||||
# 排队处理:累积到 pending,等下一次异步结果时批量追加
|
||||
self.pending.append(ev)
|
||||
self.log("SYSTEM", f"事件进入排队缓冲(当前积压 {len(self.pending)} 条)")
|
||||
|
||||
async def _handle_interrupt(self, ev: Event) -> None:
|
||||
# 1) 取消正在进行的 LLM turn
|
||||
if self.turn_task and not self.turn_task.done():
|
||||
self.turn_task.cancel()
|
||||
# 2) 取消所有后台异步工具
|
||||
cancelled = self.tasks.cancel_all()
|
||||
# 3) 组装打断批次:打断事件 + 系统回执 + 被丢弃的积压事件(留痕)
|
||||
note = Event(
|
||||
EventType.SYSTEM_NOTE,
|
||||
message={"role": "user",
|
||||
"content": (f"[系统] 已执行打断:取消了后台任务 {cancelled or '(无)'}。"
|
||||
f"请向用户简短确认已停止。")},
|
||||
label=f"打断回执,取消任务 {cancelled or '(无)'}",
|
||||
)
|
||||
batch = [ev, note] + self._drain_pending()
|
||||
await self.work.put(batch)
|
||||
|
||||
async def _worker(self) -> None:
|
||||
"""逐批处理事件:追加到轨迹后跑一轮可被取消的 LLM。"""
|
||||
while self.running:
|
||||
batch = await self.work.get()
|
||||
if batch is self._STOP:
|
||||
break
|
||||
self.turn_task = asyncio.create_task(self._process_batch(batch))
|
||||
try:
|
||||
await self.turn_task
|
||||
except asyncio.CancelledError:
|
||||
self.log("SYSTEM", "当前 LLM turn 已被打断取消")
|
||||
|
||||
async def _process_batch(self, batch: list[Event]) -> None:
|
||||
for e in batch:
|
||||
self._append(e)
|
||||
await self.run_llm_turn()
|
||||
|
||||
# ------------------------------- LLM turn -------------------------------
|
||||
|
||||
async def run_llm_turn(self) -> None:
|
||||
"""调用 LLM 做决策;同步工具就地执行并回填,异步工具启动后回占位符。"""
|
||||
for _ in range(MAX_STEPS):
|
||||
messages = self.build_messages()
|
||||
_t = time.time()
|
||||
resp = await self.client.chat.completions.create(
|
||||
model=self.model, messages=messages,
|
||||
tools=TOOL_SCHEMAS, tool_choice="auto", **self.completion_params,
|
||||
)
|
||||
self.log("SYSTEM", f"LLM 调用耗时 {time.time()-_t:.2f}s({len(messages)} 条消息)")
|
||||
msg = resp.choices[0].message
|
||||
|
||||
assistant_msg: dict = {"role": "assistant", "content": msg.content or ""}
|
||||
if msg.tool_calls:
|
||||
assistant_msg["tool_calls"] = [
|
||||
{"id": tc.id, "type": "function",
|
||||
"function": {"name": tc.function.name, "arguments": tc.function.arguments}}
|
||||
for tc in msg.tool_calls
|
||||
]
|
||||
|
||||
self._append(Event(
|
||||
EventType.AGENT_TOOL_CALL if msg.tool_calls else EventType.AGENT_OUTPUT,
|
||||
message=assistant_msg,
|
||||
label=("调用工具 " + ", ".join(tc.function.name for tc in msg.tool_calls)
|
||||
if msg.tool_calls else "回复用户"),
|
||||
))
|
||||
|
||||
if msg.content and msg.content.strip():
|
||||
self.log("AGENT", msg.content.strip())
|
||||
|
||||
if not msg.tool_calls:
|
||||
return # 本轮结束:Agent 给出了最终回复
|
||||
|
||||
# 执行每个工具调用
|
||||
for tc in msg.tool_calls:
|
||||
name = tc.function.name
|
||||
try:
|
||||
args = json.loads(tc.function.arguments or "{}")
|
||||
except json.JSONDecodeError:
|
||||
args = {}
|
||||
result_text = self._exec_tool(name, args)
|
||||
self._append(Event(
|
||||
EventType.TOOL_RESULT,
|
||||
message={"role": "tool", "tool_call_id": tc.id, "content": result_text},
|
||||
label=f"工具结果 {name}",
|
||||
))
|
||||
|
||||
def _exec_tool(self, name: str, args: dict) -> str:
|
||||
"""执行工具,返回给 LLM 的文本结果。"""
|
||||
if name == "run_terminal_command":
|
||||
command = args.get("command", "")
|
||||
state = self.tasks.start(command)
|
||||
return (f"命令已在后台【异步】启动。task_id={state.task_id},命令=`{command}`。"
|
||||
f"我不会阻塞等待;任务完成后其结果会以系统事件形式返回。"
|
||||
f"可用 query_task('{state.task_id}') 查询进度或 cancel_task('{state.task_id}') 取消。")
|
||||
|
||||
if name == "get_current_time":
|
||||
now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
self.log("TOOL", f"get_current_time -> {now}")
|
||||
return f"当前时间是 {now}。"
|
||||
|
||||
if name == "query_task":
|
||||
tid = args.get("task_id", "")
|
||||
st = self.tasks.query(tid)
|
||||
if not st:
|
||||
return f"未找到任务 {tid}。"
|
||||
self.log("TOOL", f"query_task({tid}) -> {st.status} {st.progress:.0f}%")
|
||||
return f"task_id={tid} 命令=`{st.command}` 状态={st.status} 进度={st.progress:.0f}%。"
|
||||
|
||||
if name == "cancel_task":
|
||||
tid = args.get("task_id", "")
|
||||
st = self.tasks.query(tid)
|
||||
progress = f"{st.progress:.0f}%" if st else "未知"
|
||||
ok = self.tasks.cancel(tid)
|
||||
self.log("TOOL", f"cancel_task({tid}) -> {'已取消' if ok else '无法取消'} (进度 {progress})")
|
||||
return (f"任务 {tid} 已取消(取消时进度 {progress})。" if ok
|
||||
else f"任务 {tid} 无法取消(可能已完成或不存在)。")
|
||||
|
||||
return f"未知工具:{name}"
|
||||
|
||||
# ------------------------------- 收尾 -------------------------------
|
||||
|
||||
async def wait_until_idle(self, stable: float = 1.3, timeout: float = 90.0) -> None:
|
||||
"""阻塞直到系统持续空闲 stable 秒(或超时)。"""
|
||||
start = time.time()
|
||||
last_busy = time.time()
|
||||
while True:
|
||||
busy = (self.tasks.any_running() or not self.work.empty()
|
||||
or not self.inbox.empty() or bool(self.pending)
|
||||
or (self.turn_task is not None and not self.turn_task.done()))
|
||||
now = time.time()
|
||||
if busy:
|
||||
last_busy = now
|
||||
elif now - last_busy >= stable:
|
||||
return
|
||||
if now - start >= timeout:
|
||||
self.log("SYSTEM", "wait_until_idle 超时返回")
|
||||
return
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
async def stop(self) -> None:
|
||||
self.running = False
|
||||
await self.inbox.put(self._STOP)
|
||||
|
||||
# ------------------------- 状态检查点(持久化 / 恢复) -------------------------
|
||||
|
||||
def snapshot(self) -> dict:
|
||||
"""把 Agent 的可持久化状态导出为一个 JSON 友好的字典。
|
||||
|
||||
状态 = 轨迹(工作记忆)+ 全部异步任务的最后已知状态。这是「跨会话恢复」
|
||||
的基础:进程重启后,能据此还原对话上下文与后台任务的进度。
|
||||
"""
|
||||
return {
|
||||
"model": self.model,
|
||||
"saved_at": datetime.datetime.now().isoformat(timespec="seconds"),
|
||||
"trajectory": [e.to_dict() for e in self.trajectory],
|
||||
"tasks": self.tasks.snapshot(),
|
||||
}
|
||||
|
||||
def save_checkpoint(self, path: str) -> str:
|
||||
"""把当前状态写入检查点文件(JSON),返回文件路径。"""
|
||||
data = self.snapshot()
|
||||
dirname = os.path.dirname(path)
|
||||
if dirname:
|
||||
os.makedirs(dirname, exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
self.log("STATE", f"已保存检查点 -> {path}"
|
||||
f"({len(data['trajectory'])} 条轨迹事件,{len(data['tasks'])} 个任务)")
|
||||
return path
|
||||
|
||||
def load_checkpoint(self, path: str) -> dict:
|
||||
"""从检查点文件恢复轨迹与任务状态(原地覆盖当前状态)。"""
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
trajectory = data.get("trajectory") or []
|
||||
tasks = data.get("tasks") or []
|
||||
self.trajectory = [Event.from_dict(d) for d in trajectory]
|
||||
self.tasks.restore(tasks)
|
||||
self.log("STATE", f"已从检查点恢复 <- {path}"
|
||||
f"({len(self.trajectory)} 条轨迹事件,{len(tasks)} 个任务)")
|
||||
return data
|
||||
@@ -0,0 +1,282 @@
|
||||
"""Real, bounded asynchronous terminal jobs for Experiment 6-2.
|
||||
|
||||
Commands are parsed without a shell and resolved through an explicit allowlist
|
||||
to ``analysis_worker.py``. Each job is a real child process whose stdout drives
|
||||
progress. Cancellation terminates that OS process; completion returns metrics
|
||||
computed from a real input file rather than a fabricated result string.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Awaitable, Callable, Dict, Optional
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
WORKER = HERE / "analysis_worker.py"
|
||||
DEFAULT_INPUT = HERE.parent.parent / "book" / "chapter4.md"
|
||||
|
||||
|
||||
def _env_float(name: str, default: float) -> float:
|
||||
raw = os.getenv(name)
|
||||
if raw is None:
|
||||
return default
|
||||
try:
|
||||
value = float(raw)
|
||||
if value <= 0:
|
||||
raise ValueError
|
||||
return value
|
||||
except ValueError:
|
||||
print(f"⚠️ 环境变量 {name}={raw!r} 非法(应为正数),使用默认值 {default}")
|
||||
return default
|
||||
|
||||
|
||||
# One logical second maps to this many wall-clock seconds. The default retains
|
||||
# the manuscript's 3/2/1-percent ratios while keeping the demo practical.
|
||||
TICK_REAL = _env_float("FLUX_TICK_REAL", 0.4)
|
||||
|
||||
_COMMANDS = {
|
||||
"analyze_fast.py": ("fast", 3.0),
|
||||
"analyze_mid.py": ("mid", 2.0),
|
||||
"analyze_slow.py": ("slow", 1.0),
|
||||
"analyze_logs.py": ("logs", 4.5),
|
||||
"re_run_summary.py": ("recovery", 4.5),
|
||||
}
|
||||
|
||||
|
||||
def resolve_job(command: str) -> tuple[str, float]:
|
||||
"""Resolve a displayed terminal command to one safe executable profile."""
|
||||
parts = shlex.split(command)
|
||||
if len(parts) != 2 or Path(parts[0]).name not in {"python", "python3", Path(sys.executable).name}:
|
||||
raise ValueError("only `python <approved-analysis-script>.py` commands are allowed")
|
||||
script = Path(parts[1]).name
|
||||
if script not in _COMMANDS:
|
||||
raise ValueError(f"unapproved experiment command: {script}")
|
||||
return _COMMANDS[script]
|
||||
|
||||
|
||||
def resolve_rate(command: str) -> float:
|
||||
return resolve_job(command)[1]
|
||||
|
||||
|
||||
def _hash_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
for block in iter(lambda: stream.read(1024 * 1024), b""):
|
||||
digest.update(block)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
@dataclass
|
||||
class TaskState:
|
||||
task_id: str
|
||||
command: str
|
||||
rate: float
|
||||
progress: float = 0.0
|
||||
status: str = "running" # running | completed | cancelled | failed | suspended
|
||||
result: str = ""
|
||||
pid: int | None = None
|
||||
returncode: int | None = None
|
||||
started_at: float | None = None
|
||||
completed_at: float | None = None
|
||||
stdout_sha256: str | None = None
|
||||
stderr_tail: str = ""
|
||||
executable_receipt: dict = field(default_factory=dict)
|
||||
_task: Optional[asyncio.Task] = field(default=None, repr=False)
|
||||
_process: Optional[asyncio.subprocess.Process] = field(default=None, repr=False)
|
||||
|
||||
|
||||
class TaskManager:
|
||||
"""Start, observe, query, and terminate allowlisted real subprocesses."""
|
||||
|
||||
def __init__(self, on_complete: Callable[[TaskState], Awaitable[None]],
|
||||
log: Callable[[str, str], None]):
|
||||
self._on_complete = on_complete
|
||||
self._log = log
|
||||
self._tasks: Dict[str, TaskState] = {}
|
||||
self._counter = 0
|
||||
|
||||
def start(self, command: str) -> TaskState:
|
||||
job, rate = resolve_job(command) # reject before allocating a task id
|
||||
if not WORKER.is_file() or not DEFAULT_INPUT.is_file():
|
||||
raise FileNotFoundError("analysis worker or Chapter 4 input is missing")
|
||||
self._counter += 1
|
||||
task_id = f"T{self._counter}"
|
||||
state = TaskState(task_id=task_id, command=command, rate=rate)
|
||||
state.executable_receipt = {
|
||||
"mode": "real_subprocess", "shell": False,
|
||||
"worker": str(WORKER), "worker_sha256": _hash_file(WORKER),
|
||||
"input": str(DEFAULT_INPUT), "input_sha256": _hash_file(DEFAULT_INPUT),
|
||||
"job": job, "rate_percent_per_logical_second": rate,
|
||||
"tick_real_seconds": TICK_REAL,
|
||||
}
|
||||
self._tasks[task_id] = state
|
||||
state._task = asyncio.create_task(self._run(state, job))
|
||||
self._log("TASK", f"启动真实子进程任务 {task_id}: `{command}` "
|
||||
f"(速度 {rate:.0f}%/逻辑秒)")
|
||||
return state
|
||||
|
||||
async def _terminate_process(self, state: TaskState) -> None:
|
||||
process = state._process
|
||||
if not process or process.returncode is not None:
|
||||
return
|
||||
process.terminate()
|
||||
try:
|
||||
await asyncio.wait_for(process.wait(), timeout=2)
|
||||
except asyncio.TimeoutError:
|
||||
process.kill()
|
||||
await process.wait()
|
||||
state.returncode = process.returncode
|
||||
|
||||
async def _run(self, state: TaskState, job: str) -> None:
|
||||
stdout_lines: list[str] = []
|
||||
state.started_at = time.time()
|
||||
argv = [
|
||||
sys.executable, "-I", "-u", str(WORKER),
|
||||
"--job", job, "--rate", str(state.rate),
|
||||
"--tick-real", str(TICK_REAL), "--input", str(DEFAULT_INPUT),
|
||||
]
|
||||
state.executable_receipt["argv_sha256"] = hashlib.sha256(
|
||||
json.dumps(argv, separators=(",", ":")).encode()
|
||||
).hexdigest()
|
||||
next_milestone = 20.0
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*argv, cwd=str(HERE),
|
||||
stdin=asyncio.subprocess.DEVNULL,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
state._process = process
|
||||
state.pid = process.pid
|
||||
state.executable_receipt["pid"] = process.pid
|
||||
assert process.stdout is not None
|
||||
while True:
|
||||
raw = await process.stdout.readline()
|
||||
if not raw:
|
||||
break
|
||||
line = raw.decode("utf-8", errors="replace").rstrip()
|
||||
stdout_lines.append(line)
|
||||
if line.startswith("PROGRESS "):
|
||||
try:
|
||||
state.progress = max(
|
||||
state.progress, min(100.0, float(line.split()[1]))
|
||||
)
|
||||
except (IndexError, ValueError):
|
||||
raise RuntimeError(f"worker emitted invalid progress: {line!r}")
|
||||
if state.progress >= next_milestone:
|
||||
self._log("TASK", f"{state.task_id} `{state.command}` "
|
||||
f"进度 {state.progress:.0f}% (pid={state.pid})")
|
||||
next_milestone += 20.0
|
||||
elif line.startswith("RESULT "):
|
||||
payload = json.loads(line.removeprefix("RESULT "))
|
||||
state.result = json.dumps(payload, ensure_ascii=False, sort_keys=True)
|
||||
assert process.stderr is not None
|
||||
stderr = (await process.stderr.read()).decode("utf-8", errors="replace")
|
||||
state.stderr_tail = stderr[-4000:]
|
||||
state.returncode = await process.wait()
|
||||
state.completed_at = time.time()
|
||||
stdout = "\n".join(stdout_lines) + ("\n" if stdout_lines else "")
|
||||
state.stdout_sha256 = hashlib.sha256(stdout.encode()).hexdigest()
|
||||
state.executable_receipt.update({
|
||||
"returncode": state.returncode,
|
||||
"stdout_sha256": state.stdout_sha256,
|
||||
"stdout_lines": len(stdout_lines),
|
||||
"stderr_sha256": hashlib.sha256(stderr.encode()).hexdigest(),
|
||||
"elapsed_seconds": round(state.completed_at - state.started_at, 3),
|
||||
})
|
||||
if state.returncode != 0:
|
||||
state.status = "failed"
|
||||
raise RuntimeError(
|
||||
f"worker exited {state.returncode}: {state.stderr_tail[-500:]}"
|
||||
)
|
||||
if state.progress != 100.0 or not state.result:
|
||||
state.status = "failed"
|
||||
raise RuntimeError("worker completed without 100% progress and a RESULT receipt")
|
||||
state.status = "completed"
|
||||
self._log("TASK", f"{state.task_id} 完成 ✅ (pid={state.pid}, "
|
||||
f"returncode={state.returncode})")
|
||||
await self._on_complete(state)
|
||||
except asyncio.CancelledError:
|
||||
await self._terminate_process(state)
|
||||
state.status = "cancelled"
|
||||
state.completed_at = time.time()
|
||||
state.executable_receipt.update({
|
||||
"returncode": state.returncode,
|
||||
"cancelled": True,
|
||||
"elapsed_seconds": round(state.completed_at - state.started_at, 3)
|
||||
if state.started_at else None,
|
||||
})
|
||||
self._log("TASK", f"{state.task_id} 子进程已终止 🛑 "
|
||||
f"(pid={state.pid}, 进度 {state.progress:.0f}%)")
|
||||
raise
|
||||
except Exception as exc:
|
||||
await self._terminate_process(state)
|
||||
state.status = "failed"
|
||||
state.result = state.result or f"{type(exc).__name__}: {exc}"
|
||||
state.completed_at = time.time()
|
||||
self._log("TASK", f"{state.task_id} 失败 ❌: {exc}")
|
||||
|
||||
def query(self, task_id: str) -> Optional[TaskState]:
|
||||
return self._tasks.get(task_id)
|
||||
|
||||
def cancel(self, task_id: str) -> bool:
|
||||
state = self._tasks.get(task_id)
|
||||
if state and state.status == "running":
|
||||
if state._task:
|
||||
state._task.cancel()
|
||||
return True
|
||||
return False
|
||||
|
||||
def cancel_all(self) -> list[str]:
|
||||
cancelled = []
|
||||
for task_id, state in self._tasks.items():
|
||||
if state.status == "running":
|
||||
if state._task:
|
||||
state._task.cancel()
|
||||
cancelled.append(task_id)
|
||||
return cancelled
|
||||
|
||||
def any_running(self) -> bool:
|
||||
return any(state.status == "running" for state in self._tasks.values())
|
||||
|
||||
def all_states(self) -> list[TaskState]:
|
||||
return list(self._tasks.values())
|
||||
|
||||
def snapshot(self) -> list[dict]:
|
||||
return [
|
||||
{"task_id": state.task_id, "command": state.command,
|
||||
"rate": state.rate, "progress": state.progress,
|
||||
"status": state.status, "result": state.result,
|
||||
"pid": state.pid, "returncode": state.returncode,
|
||||
"started_at": state.started_at, "completed_at": state.completed_at,
|
||||
"stdout_sha256": state.stdout_sha256,
|
||||
"executable_receipt": state.executable_receipt}
|
||||
for state in self._tasks.values()
|
||||
]
|
||||
|
||||
def restore(self, records: list[dict]) -> None:
|
||||
for record in records:
|
||||
status = "suspended" if record["status"] == "running" else record["status"]
|
||||
receipt = record.get("executable_receipt")
|
||||
state = TaskState(
|
||||
task_id=record["task_id"], command=record["command"],
|
||||
rate=record["rate"], progress=record["progress"], status=status,
|
||||
result=record.get("result") or "", pid=record.get("pid"),
|
||||
returncode=record.get("returncode"),
|
||||
started_at=record.get("started_at"), completed_at=record.get("completed_at"),
|
||||
stdout_sha256=record.get("stdout_sha256"),
|
||||
executable_receipt=receipt if isinstance(receipt, dict) else {},
|
||||
)
|
||||
self._tasks[state.task_id] = state
|
||||
try:
|
||||
self._counter = max(self._counter, int(state.task_id.lstrip("T") or 0))
|
||||
except ValueError:
|
||||
pass
|
||||
@@ -0,0 +1,101 @@
|
||||
"""
|
||||
Test suite locking out TypeError and FileNotFoundError in AgentRuntime checkpointing
|
||||
when tasks is None, trajectory is None, or destination directory doesn't exist.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
sys.path.insert(0, os.path.abspath(os.path.dirname(__file__)))
|
||||
|
||||
from runtime import AgentRuntime
|
||||
from tasks import TaskManager
|
||||
|
||||
|
||||
def test_save_checkpoint_creates_nested_directories():
|
||||
"""
|
||||
Ensure save_checkpoint automatically creates parent directories when saving.
|
||||
"""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
runtime = AgentRuntime.__new__(AgentRuntime)
|
||||
runtime.snapshot = MagicMock(return_value={'trajectory': [], 'tasks': []})
|
||||
runtime.log = MagicMock()
|
||||
|
||||
target_path = os.path.join(tmpdir, "nested", "sub", "checkpoint.json")
|
||||
result_path = runtime.save_checkpoint(target_path)
|
||||
|
||||
assert result_path == target_path
|
||||
assert os.path.exists(target_path)
|
||||
|
||||
|
||||
def test_load_checkpoint_handles_null_tasks_and_trajectory():
|
||||
"""
|
||||
Ensure load_checkpoint gracefully handles JSON containing "tasks": null and
|
||||
"trajectory": null with a real TaskManager without raising TypeError.
|
||||
"""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
target_path = os.path.join(tmpdir, "checkpoint.json")
|
||||
with open(target_path, "w", encoding="utf-8") as f:
|
||||
json.dump({'trajectory': None, 'tasks': None}, f)
|
||||
|
||||
runtime = AgentRuntime.__new__(AgentRuntime)
|
||||
runtime.tasks = TaskManager(on_complete=MagicMock(), log=MagicMock())
|
||||
runtime.log = MagicMock()
|
||||
|
||||
data = runtime.load_checkpoint(target_path)
|
||||
assert data['tasks'] is None
|
||||
assert data['trajectory'] is None
|
||||
assert len(runtime.trajectory) == 0
|
||||
assert len(runtime.tasks._tasks) == 0
|
||||
runtime.log.assert_called_once()
|
||||
|
||||
def test_load_checkpoint_handles_null_task_fields_and_event_fields():
|
||||
"""
|
||||
Ensure load_checkpoint gracefully handles JSON containing null fields in
|
||||
task records and trajectory events without setting None for non-optional attributes.
|
||||
"""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
target_path = os.path.join(tmpdir, "checkpoint.json")
|
||||
checkpoint_data = {
|
||||
'trajectory': [
|
||||
{
|
||||
'type': 'user.input',
|
||||
'message': {'role': 'user', 'content': 'hello'},
|
||||
'label': None,
|
||||
'ts': None,
|
||||
}
|
||||
],
|
||||
'tasks': [
|
||||
{
|
||||
'task_id': 'T1',
|
||||
'command': 'python analyze_logs.py',
|
||||
'rate': 50.0,
|
||||
'progress': 100.0,
|
||||
'status': 'completed',
|
||||
'result': None,
|
||||
'executable_receipt': None,
|
||||
}
|
||||
]
|
||||
}
|
||||
with open(target_path, "w", encoding="utf-8") as f:
|
||||
json.dump(checkpoint_data, f)
|
||||
|
||||
runtime = AgentRuntime.__new__(AgentRuntime)
|
||||
runtime.tasks = TaskManager(on_complete=MagicMock(), log=MagicMock())
|
||||
runtime.log = MagicMock()
|
||||
|
||||
runtime.load_checkpoint(target_path)
|
||||
|
||||
ev = runtime.trajectory[0]
|
||||
assert isinstance(ev.label, str)
|
||||
assert ev.label == ""
|
||||
assert isinstance(ev.ts, float)
|
||||
|
||||
st = runtime.tasks.query('T1')
|
||||
assert isinstance(st.result, str)
|
||||
assert st.result == ""
|
||||
assert isinstance(st.executable_receipt, dict)
|
||||
assert st.executable_receipt == {}
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Acceptance-ledger regression tests for the durable Experiment 6-2 run."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE))
|
||||
PATH = HERE / "run_real_experiment.py"
|
||||
SPEC = importlib.util.spec_from_file_location("experiment_6_2_real", PATH)
|
||||
runner = importlib.util.module_from_spec(SPEC)
|
||||
assert SPEC.loader is not None
|
||||
sys.modules[SPEC.name] = runner
|
||||
SPEC.loader.exec_module(runner)
|
||||
|
||||
|
||||
def _campaign() -> tuple[list[dict], dict]:
|
||||
root = HERE / "validation" / "experiment_6_2"
|
||||
campaigns = sorted(path for path in root.iterdir() if path.is_dir())
|
||||
assert campaigns, "a durable Experiment 6-2 campaign is required"
|
||||
campaign = campaigns[-1]
|
||||
scenarios = [json.loads(path.read_text(encoding="utf-8"))
|
||||
for path in sorted((campaign / "scenarios").glob("*.json"))]
|
||||
protocol = json.loads((campaign / "protocol.json").read_text(encoding="utf-8"))
|
||||
return scenarios, protocol
|
||||
|
||||
|
||||
def test_durable_campaign_passes_every_derived_gate():
|
||||
scenarios, protocol = _campaign()
|
||||
acceptance = runner.derive_acceptance(scenarios, protocol)
|
||||
assert acceptance["status"] == "passed"
|
||||
assert all(acceptance["gates"].values())
|
||||
|
||||
|
||||
def test_simulated_or_missing_process_receipt_cannot_pass():
|
||||
scenarios, protocol = _campaign()
|
||||
tampered = copy.deepcopy(scenarios)
|
||||
tampered[0]["tasks"][0]["executable"]["mode"] = "simulated"
|
||||
acceptance = runner.derive_acceptance(tampered, protocol)
|
||||
assert acceptance["status"] == "failed"
|
||||
assert not acceptance["gates"]["real_subprocess_receipts_only"]
|
||||
|
||||
|
||||
def test_empty_evidence_fails_closed():
|
||||
protocol = json.loads((HERE / "experiment_protocol.json").read_text(encoding="utf-8"))
|
||||
acceptance = runner.derive_acceptance([], protocol)
|
||||
assert acceptance["status"] == "failed"
|
||||
assert not any(acceptance["gates"].values())
|
||||
|
||||
|
||||
|
||||
def test_protocol_coverage_mapping_is_complete_and_enforced():
|
||||
"""Every protocol acceptance key must map to at least one gate, and the
|
||||
coverage report must reflect gate pass/fail status correctly."""
|
||||
scenarios, protocol = _campaign()
|
||||
acceptance = runner.derive_acceptance(scenarios, protocol)
|
||||
coverage = acceptance["protocol_coverage"]
|
||||
# Every protocol acceptance key must appear in the coverage report.
|
||||
protocol_keys = set(protocol.get("acceptance", {}))
|
||||
assert set(coverage) == protocol_keys, (
|
||||
f"coverage keys {set(coverage)} != protocol keys {protocol_keys}"
|
||||
)
|
||||
# Every coverage entry must reference at least one gate key.
|
||||
for proto_key, entry in coverage.items():
|
||||
assert len(entry["enforced_by"]) >= 1, f"{proto_key} has no enforcing gate"
|
||||
# When all gates pass, every coverage entry must report all_gates_passed=True.
|
||||
if acceptance["status"] == "passed":
|
||||
assert all(entry["all_gates_passed"] for entry in coverage.values())
|
||||
|
||||
|
||||
def test_protocol_coverage_detects_unmapped_acceptance_key():
|
||||
"""Adding an acceptance key to the protocol without a PROTOCOL_TO_GATE
|
||||
mapping must raise an assertion at run time."""
|
||||
scenarios, protocol = _campaign()
|
||||
tampered_protocol = copy.deepcopy(protocol)
|
||||
tampered_protocol["acceptance"]["bogus_unmapped_key"] = "must be enforced"
|
||||
try:
|
||||
runner.derive_acceptance(scenarios, tampered_protocol)
|
||||
except AssertionError as exc:
|
||||
assert "bogus_unmapped_key" in str(exc)
|
||||
else:
|
||||
raise AssertionError("expected AssertionError for unmapped acceptance key")
|
||||
|
||||
|
||||
def test_protocol_coverage_reflects_gate_failure():
|
||||
"""When a gate fails, the coverage entries that depend on it must report
|
||||
all_gates_passed=False."""
|
||||
scenarios, protocol = _campaign()
|
||||
tampered = copy.deepcopy(scenarios)
|
||||
tampered[0]["tasks"][0]["executable"]["mode"] = "simulated"
|
||||
acceptance = runner.derive_acceptance(tampered, protocol)
|
||||
coverage = acceptance["protocol_coverage"]
|
||||
# real_subprocess_receipts_only gate should have failed.
|
||||
assert not acceptance["gates"]["real_subprocess_receipts_only"]
|
||||
# Every protocol key enforced by that gate must report failure.
|
||||
for proto_key, entry in coverage.items():
|
||||
if "real_subprocess_receipts_only" in entry["enforced_by"]:
|
||||
assert not entry["all_gates_passed"], (
|
||||
f"{proto_key} should report gate failure"
|
||||
)
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Contract tests for real subprocess-backed Experiment 6-2 tasks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE))
|
||||
|
||||
import tasks
|
||||
|
||||
|
||||
def test_unapproved_commands_are_rejected_before_execution():
|
||||
with pytest.raises(ValueError, match="unapproved"):
|
||||
tasks.resolve_job("python arbitrary.py")
|
||||
with pytest.raises(ValueError, match="only"):
|
||||
tasks.resolve_job("sh -c 'echo unsafe'")
|
||||
|
||||
|
||||
def test_real_subprocess_completes_with_observed_metrics(monkeypatch):
|
||||
async def scenario():
|
||||
monkeypatch.setattr(tasks, "TICK_REAL", 0.002)
|
||||
completed = []
|
||||
|
||||
async def on_complete(state):
|
||||
completed.append(state.task_id)
|
||||
|
||||
manager = tasks.TaskManager(on_complete, lambda *_: None)
|
||||
state = manager.start("python analyze_fast.py")
|
||||
assert state._task is not None
|
||||
await state._task
|
||||
result = json.loads(state.result)
|
||||
assert completed == [state.task_id]
|
||||
assert state.status == "completed"
|
||||
assert state.pid and state.pid != os.getpid()
|
||||
assert state.returncode == 0
|
||||
assert state.progress == 100
|
||||
assert state.stdout_sha256 and len(state.stdout_sha256) == 64
|
||||
assert state.executable_receipt["mode"] == "real_subprocess"
|
||||
assert state.executable_receipt["shell"] is False
|
||||
assert state.executable_receipt["returncode"] == 0
|
||||
assert result["input_sha256"] == hashlib.sha256(
|
||||
tasks.DEFAULT_INPUT.read_bytes()
|
||||
).hexdigest()
|
||||
assert result["bytes"] == tasks.DEFAULT_INPUT.stat().st_size
|
||||
assert result["lines"] > 100
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
def test_cancel_terminates_real_child_process_and_freezes_progress(monkeypatch):
|
||||
async def scenario():
|
||||
monkeypatch.setattr(tasks, "TICK_REAL", 0.02)
|
||||
|
||||
async def on_complete(_):
|
||||
raise AssertionError("cancelled process must not complete")
|
||||
|
||||
manager = tasks.TaskManager(on_complete, lambda *_: None)
|
||||
state = manager.start("python analyze_slow.py")
|
||||
while state.pid is None:
|
||||
await asyncio.sleep(0.001)
|
||||
await asyncio.sleep(0.05)
|
||||
pid = state.pid
|
||||
assert manager.cancel(state.task_id)
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await state._task
|
||||
frozen = state.progress
|
||||
await asyncio.sleep(0.05)
|
||||
assert state.status == "cancelled"
|
||||
assert state.progress == frozen < 100
|
||||
assert state.executable_receipt["cancelled"] is True
|
||||
assert state.returncode is not None
|
||||
with pytest.raises(ProcessLookupError):
|
||||
os.kill(pid, 0)
|
||||
|
||||
asyncio.run(scenario())
|
||||
@@ -0,0 +1,9 @@
|
||||
"""Test import bootstrap for the async-agent experiment."""
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
|
||||
EXPERIMENT_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(EXPERIMENT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(EXPERIMENT_ROOT))
|
||||
@@ -0,0 +1,35 @@
|
||||
"""回归测试:FLUX_TICK_REAL 等浮点环境变量非法时不得让模块导入崩溃。
|
||||
|
||||
tasks.py 原来在模块导入时用裸 float() 解析 FLUX_TICK_REAL,
|
||||
FLUX_TICK_REAL=abc 会让整个演示脚本以 ValueError 崩溃;现在回退到默认值并打印警告。
|
||||
"""
|
||||
import importlib
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
import tasks
|
||||
|
||||
|
||||
def test_env_float_falls_back_on_malformed(monkeypatch, capsys):
|
||||
monkeypatch.setenv("FLUX_TICK_REAL", "abc")
|
||||
assert tasks._env_float("FLUX_TICK_REAL", 0.4) == 0.4
|
||||
assert "FLUX_TICK_REAL" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_env_float_parses_valid_value(monkeypatch):
|
||||
monkeypatch.setenv("FLUX_TICK_REAL", "0.1")
|
||||
assert tasks._env_float("FLUX_TICK_REAL", 0.4) == 0.1
|
||||
|
||||
|
||||
def test_env_float_default_when_unset(monkeypatch):
|
||||
monkeypatch.delenv("FLUX_TICK_REAL", raising=False)
|
||||
assert tasks._env_float("FLUX_TICK_REAL", 0.4) == 0.4
|
||||
|
||||
|
||||
def test_module_reload_survives_malformed_env(monkeypatch):
|
||||
"""模块级 TICK_REAL 在环境变量非法时不得抛出 ValueError。"""
|
||||
monkeypatch.setenv("FLUX_TICK_REAL", "fast")
|
||||
importlib.reload(tasks)
|
||||
assert tasks.TICK_REAL == 0.4
|
||||
+1
@@ -0,0 +1 @@
|
||||
<!DOCTYPE html><html lang="ja"><meta charset="utf-8"><title>非同期分析レポート</title><body><h1>分析結果</h1><p>対象ファイルは 679 行、106193 バイトです。</p><p>非同期に関する言及は 69 件でした。</p></body></html>
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"first_completed": "T1",
|
||||
"query_receipts": [
|
||||
{
|
||||
"task_id": "T2",
|
||||
"status": "running",
|
||||
"progress": 68.0,
|
||||
"queried_at": 5.345
|
||||
},
|
||||
{
|
||||
"task_id": "T3",
|
||||
"status": "running",
|
||||
"progress": 34.0,
|
||||
"queried_at": 5.345
|
||||
}
|
||||
],
|
||||
"cancelled_ids": [
|
||||
"T3"
|
||||
],
|
||||
"completed_results": {
|
||||
"T1": {
|
||||
"async_mentions": 69,
|
||||
"bytes": 106193,
|
||||
"error_keyword_count": 20,
|
||||
"experiment_mentions": 37,
|
||||
"heading_count": 27,
|
||||
"input_path": "/Users/boj/book/ai-agent-book/book/chapter6.md",
|
||||
"input_sha256": "4184460334544d326bb7ad4db349b8c583790195d51efc28ef325284c41d7fdd",
|
||||
"job": "fast",
|
||||
"lines": 679
|
||||
},
|
||||
"T2": {
|
||||
"async_mentions": 69,
|
||||
"bytes": 106193,
|
||||
"error_keyword_count": 20,
|
||||
"experiment_mentions": 37,
|
||||
"heading_count": 27,
|
||||
"input_path": "/Users/boj/book/ai-agent-book/book/chapter6.md",
|
||||
"input_sha256": "4184460334544d326bb7ad4db349b8c583790195d51efc28ef325284c41d7fdd",
|
||||
"job": "mid",
|
||||
"lines": 679
|
||||
}
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"generated_at": "2026-07-29T21:28:45.233367+00:00",
|
||||
"files": [
|
||||
{
|
||||
"path": "artifacts/scenario_2_report.html",
|
||||
"bytes": 257,
|
||||
"sha256": "e468c5a2c05f5fe74b2c41ca123676ab2f325fda41869a615545221b7d1c7a2f"
|
||||
},
|
||||
{
|
||||
"path": "artifacts/scenario_4_report.json",
|
||||
"bytes": 1071,
|
||||
"sha256": "7bc8f53d28a8ec058424beb24ee1fcf9e36dd6c8b01e16fbea8dcc8d5b5dead7"
|
||||
},
|
||||
{
|
||||
"path": "protocol.json",
|
||||
"bytes": 1130,
|
||||
"sha256": "2d15d0bfd8314d4ae141dfb72539703fdcd9fa6aed9bd34082a54df6694b42d3"
|
||||
},
|
||||
{
|
||||
"path": "scenarios/async_command_and_immediate_question.json",
|
||||
"bytes": 3438,
|
||||
"sha256": "24677cf6db74cfefee1f6a856254ee6b4d7bbde2de03f3015966bb86aa473f8d"
|
||||
},
|
||||
{
|
||||
"path": "scenarios/interrupt_terminates_and_recovers.json",
|
||||
"bytes": 4995,
|
||||
"sha256": "d355ea15acc9b85d789460a215f7cd36b4ee9549194b87a328515f17de8bbcac"
|
||||
},
|
||||
{
|
||||
"path": "scenarios/parallel_progress_threshold_cancellation.json",
|
||||
"bytes": 9035,
|
||||
"sha256": "3e6ec09e98703ef4309d55d2386700de8f6ea8c7daeda660ee49ee14c8bbedf4"
|
||||
},
|
||||
{
|
||||
"path": "scenarios/queued_batch_to_japanese_html.json",
|
||||
"bytes": 4089,
|
||||
"sha256": "796bf2d2878a6681d897d42938836f29e301d0c4fe6e6fbb78975427dae4aca9"
|
||||
},
|
||||
{
|
||||
"path": "summary.json",
|
||||
"bytes": 1097,
|
||||
"sha256": "913b5b9b73d61bcf02c51a44c5ed204dbb6ca5abbe8f133503fd88690ae5f788"
|
||||
}
|
||||
]
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"experiment": "6-2",
|
||||
"title": "Real subprocess async execution, queueing, interruption, and progress cancellation",
|
||||
"authority": "book/chapter6.md:579",
|
||||
"execution": {
|
||||
"mode": "allowlisted asyncio subprocesses",
|
||||
"shell": false,
|
||||
"worker": "analysis_worker.py",
|
||||
"input": "book/chapter6.md",
|
||||
"progress_source": "child stdout",
|
||||
"result_source": "child-computed file metrics"
|
||||
},
|
||||
"scenarios": [
|
||||
"long command plus immediate current-time response before completion",
|
||||
"two deferred instructions batch on completion and produce Japanese HTML",
|
||||
"user cancellation terminates the child process and runtime recovers",
|
||||
"3/2/1 percent jobs; query remaining jobs once; cancel only progress at or below 50 percent"
|
||||
],
|
||||
"acceptance": {
|
||||
"long_job_at_least_three_seconds": true,
|
||||
"placeholder_return_is_nonblocking": true,
|
||||
"all_terminal_jobs_are_real_subprocesses": true,
|
||||
"cancelled_jobs_have_os_return_codes": true,
|
||||
"completed_jobs_have_stdout_and_input_hashes": true,
|
||||
"all_artifacts_are_hash_manifested": true,
|
||||
"no_simulated_terminal_result_can_pass": true
|
||||
}
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
{
|
||||
"id": "async_command_and_immediate_question",
|
||||
"placeholder_latency_seconds": 0.000241,
|
||||
"placeholder_at": 0.0,
|
||||
"time_answer_at": 0.502,
|
||||
"completion_event_at": 3.635,
|
||||
"time_answer": "2026-07-30T05:28:26+08:00",
|
||||
"events": [
|
||||
{
|
||||
"elapsed_seconds": 0.0,
|
||||
"source": "TASK",
|
||||
"text": "启动真实子进程任务 T1: `python analyze_logs.py` (速度 4%/逻辑秒)"
|
||||
},
|
||||
{
|
||||
"elapsed_seconds": 0.0,
|
||||
"source": "TOOL",
|
||||
"event": "placeholder_returned",
|
||||
"task_id": "T1",
|
||||
"placeholder_latency_seconds": 0.000241
|
||||
},
|
||||
{
|
||||
"elapsed_seconds": 0.502,
|
||||
"source": "AGENT",
|
||||
"event": "immediate_time_answer",
|
||||
"answer": "2026-07-30T05:28:26+08:00",
|
||||
"task_still_running": true
|
||||
},
|
||||
{
|
||||
"elapsed_seconds": 0.803,
|
||||
"source": "TASK",
|
||||
"text": "T1 `python analyze_logs.py` 进度 22% (pid=92016)"
|
||||
},
|
||||
{
|
||||
"elapsed_seconds": 1.426,
|
||||
"source": "TASK",
|
||||
"text": "T1 `python analyze_logs.py` 进度 40% (pid=92016)"
|
||||
},
|
||||
{
|
||||
"elapsed_seconds": 2.207,
|
||||
"source": "TASK",
|
||||
"text": "T1 `python analyze_logs.py` 进度 63% (pid=92016)"
|
||||
},
|
||||
{
|
||||
"elapsed_seconds": 2.84,
|
||||
"source": "TASK",
|
||||
"text": "T1 `python analyze_logs.py` 进度 81% (pid=92016)"
|
||||
},
|
||||
{
|
||||
"elapsed_seconds": 3.622,
|
||||
"source": "TASK",
|
||||
"text": "T1 `python analyze_logs.py` 进度 100% (pid=92016)"
|
||||
},
|
||||
{
|
||||
"elapsed_seconds": 3.635,
|
||||
"source": "TASK",
|
||||
"text": "T1 完成 ✅ (pid=92016, returncode=0)"
|
||||
},
|
||||
{
|
||||
"elapsed_seconds": 3.635,
|
||||
"source": "SYSTEM",
|
||||
"event": "async_result_injected",
|
||||
"task_id": "T1"
|
||||
}
|
||||
],
|
||||
"tasks": [
|
||||
{
|
||||
"task_id": "T1",
|
||||
"command": "python analyze_logs.py",
|
||||
"status": "completed",
|
||||
"progress": 100.0,
|
||||
"pid": 92016,
|
||||
"returncode": 0,
|
||||
"started_at": 1785360505.728322,
|
||||
"completed_at": 1785360509.363023,
|
||||
"elapsed_seconds": 3.635,
|
||||
"stdout_sha256": "7da29da059869ca8f939dd6d0d4c4a299d9e02a53592773c71517971af545ebd",
|
||||
"stderr_tail": "",
|
||||
"result": {
|
||||
"async_mentions": 69,
|
||||
"bytes": 106193,
|
||||
"error_keyword_count": 20,
|
||||
"experiment_mentions": 37,
|
||||
"heading_count": 27,
|
||||
"input_path": "/Users/boj/book/ai-agent-book/book/chapter6.md",
|
||||
"input_sha256": "4184460334544d326bb7ad4db349b8c583790195d51efc28ef325284c41d7fdd",
|
||||
"job": "logs",
|
||||
"lines": 679
|
||||
},
|
||||
"executable": {
|
||||
"mode": "real_subprocess",
|
||||
"shell": false,
|
||||
"worker": "/Users/boj/book/ai-agent-book/chapter6/async-agent/analysis_worker.py",
|
||||
"worker_sha256": "ab700e034f63e4b8f83dfb4a6156745b7ab7c9352750c63a900f2632acb134c7",
|
||||
"input": "/Users/boj/book/ai-agent-book/book/chapter6.md",
|
||||
"input_sha256": "4184460334544d326bb7ad4db349b8c583790195d51efc28ef325284c41d7fdd",
|
||||
"job": "logs",
|
||||
"rate_percent_per_logical_second": 4.5,
|
||||
"tick_real_seconds": 0.15,
|
||||
"argv_sha256": "1c6aa3b4072446e8286dce865f45c6bb1c93733319bf7501adbb555bc6a7973c",
|
||||
"pid": 92016,
|
||||
"returncode": 0,
|
||||
"stdout_sha256": "7da29da059869ca8f939dd6d0d4c4a299d9e02a53592773c71517971af545ebd",
|
||||
"stdout_lines": 24,
|
||||
"stderr_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"elapsed_seconds": 3.635
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
{
|
||||
"id": "interrupt_terminates_and_recovers",
|
||||
"interrupt_at": 0.803,
|
||||
"cancel_receipt_at": 0.806,
|
||||
"cancel_latency_seconds": 0.004,
|
||||
"recovery_at": 0.807,
|
||||
"completed_callbacks": [
|
||||
"T2"
|
||||
],
|
||||
"events": [
|
||||
{
|
||||
"elapsed_seconds": 0.0,
|
||||
"source": "TASK",
|
||||
"text": "启动真实子进程任务 T1: `python analyze_logs.py` (速度 4%/逻辑秒)"
|
||||
},
|
||||
{
|
||||
"elapsed_seconds": 0.803,
|
||||
"source": "USER",
|
||||
"event": "user.interrupt",
|
||||
"text": "取消",
|
||||
"task_id": "T1",
|
||||
"progress": 18.0
|
||||
},
|
||||
{
|
||||
"elapsed_seconds": 0.806,
|
||||
"source": "TASK",
|
||||
"text": "T1 子进程已终止 🛑 (pid=92040, 进度 18%)"
|
||||
},
|
||||
{
|
||||
"elapsed_seconds": 0.806,
|
||||
"source": "SYSTEM",
|
||||
"event": "process_cancelled",
|
||||
"task_ids": [
|
||||
"T1"
|
||||
],
|
||||
"cancel_latency_seconds": 0.004,
|
||||
"returncode": -15
|
||||
},
|
||||
{
|
||||
"elapsed_seconds": 0.807,
|
||||
"source": "TASK",
|
||||
"text": "启动真实子进程任务 T2: `python re_run_summary.py` (速度 4%/逻辑秒)"
|
||||
},
|
||||
{
|
||||
"elapsed_seconds": 0.807,
|
||||
"source": "SYSTEM",
|
||||
"event": "runtime_recovered",
|
||||
"task_id": "T2"
|
||||
},
|
||||
{
|
||||
"elapsed_seconds": 1.619,
|
||||
"source": "TASK",
|
||||
"text": "T2 `python re_run_summary.py` 进度 22% (pid=92041)"
|
||||
},
|
||||
{
|
||||
"elapsed_seconds": 2.245,
|
||||
"source": "TASK",
|
||||
"text": "T2 `python re_run_summary.py` 进度 40% (pid=92041)"
|
||||
},
|
||||
{
|
||||
"elapsed_seconds": 3.019,
|
||||
"source": "TASK",
|
||||
"text": "T2 `python re_run_summary.py` 进度 63% (pid=92041)"
|
||||
},
|
||||
{
|
||||
"elapsed_seconds": 3.644,
|
||||
"source": "TASK",
|
||||
"text": "T2 `python re_run_summary.py` 进度 81% (pid=92041)"
|
||||
},
|
||||
{
|
||||
"elapsed_seconds": 4.423,
|
||||
"source": "TASK",
|
||||
"text": "T2 `python re_run_summary.py` 进度 100% (pid=92041)"
|
||||
},
|
||||
{
|
||||
"elapsed_seconds": 4.436,
|
||||
"source": "TASK",
|
||||
"text": "T2 完成 ✅ (pid=92041, returncode=0)"
|
||||
},
|
||||
{
|
||||
"elapsed_seconds": 4.436,
|
||||
"source": "SYSTEM",
|
||||
"event": "async_result_injected",
|
||||
"task_id": "T2"
|
||||
}
|
||||
],
|
||||
"tasks": [
|
||||
{
|
||||
"task_id": "T1",
|
||||
"command": "python analyze_logs.py",
|
||||
"status": "cancelled",
|
||||
"progress": 18.0,
|
||||
"pid": 92040,
|
||||
"returncode": -15,
|
||||
"started_at": 1785360512.964798,
|
||||
"completed_at": 1785360513.7706082,
|
||||
"elapsed_seconds": 0.806,
|
||||
"stdout_sha256": null,
|
||||
"stderr_tail": "",
|
||||
"result": null,
|
||||
"executable": {
|
||||
"mode": "real_subprocess",
|
||||
"shell": false,
|
||||
"worker": "/Users/boj/book/ai-agent-book/chapter6/async-agent/analysis_worker.py",
|
||||
"worker_sha256": "ab700e034f63e4b8f83dfb4a6156745b7ab7c9352750c63a900f2632acb134c7",
|
||||
"input": "/Users/boj/book/ai-agent-book/book/chapter6.md",
|
||||
"input_sha256": "4184460334544d326bb7ad4db349b8c583790195d51efc28ef325284c41d7fdd",
|
||||
"job": "logs",
|
||||
"rate_percent_per_logical_second": 4.5,
|
||||
"tick_real_seconds": 0.15,
|
||||
"argv_sha256": "1c6aa3b4072446e8286dce865f45c6bb1c93733319bf7501adbb555bc6a7973c",
|
||||
"pid": 92040,
|
||||
"returncode": -15,
|
||||
"cancelled": true,
|
||||
"elapsed_seconds": 0.806
|
||||
}
|
||||
},
|
||||
{
|
||||
"task_id": "T2",
|
||||
"command": "python re_run_summary.py",
|
||||
"status": "completed",
|
||||
"progress": 100.0,
|
||||
"pid": 92041,
|
||||
"returncode": 0,
|
||||
"started_at": 1785360513.771501,
|
||||
"completed_at": 1785360517.4005382,
|
||||
"elapsed_seconds": 3.629,
|
||||
"stdout_sha256": "5c538eaf31540bcc95acb80a9ca3333a440020c63e015b644575a35cabbe6a11",
|
||||
"stderr_tail": "",
|
||||
"result": {
|
||||
"async_mentions": 69,
|
||||
"bytes": 106193,
|
||||
"error_keyword_count": 20,
|
||||
"experiment_mentions": 37,
|
||||
"heading_count": 27,
|
||||
"input_path": "/Users/boj/book/ai-agent-book/book/chapter6.md",
|
||||
"input_sha256": "4184460334544d326bb7ad4db349b8c583790195d51efc28ef325284c41d7fdd",
|
||||
"job": "recovery",
|
||||
"lines": 679
|
||||
},
|
||||
"executable": {
|
||||
"mode": "real_subprocess",
|
||||
"shell": false,
|
||||
"worker": "/Users/boj/book/ai-agent-book/chapter6/async-agent/analysis_worker.py",
|
||||
"worker_sha256": "ab700e034f63e4b8f83dfb4a6156745b7ab7c9352750c63a900f2632acb134c7",
|
||||
"input": "/Users/boj/book/ai-agent-book/book/chapter6.md",
|
||||
"input_sha256": "4184460334544d326bb7ad4db349b8c583790195d51efc28ef325284c41d7fdd",
|
||||
"job": "recovery",
|
||||
"rate_percent_per_logical_second": 4.5,
|
||||
"tick_real_seconds": 0.15,
|
||||
"argv_sha256": "7079826b54049bc6e5276ade7f4c343a41b9fc82f828d35685665b3101bce693",
|
||||
"pid": 92041,
|
||||
"returncode": 0,
|
||||
"stdout_sha256": "5c538eaf31540bcc95acb80a9ca3333a440020c63e015b644575a35cabbe6a11",
|
||||
"stdout_lines": 24,
|
||||
"stderr_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"elapsed_seconds": 3.629
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+286
@@ -0,0 +1,286 @@
|
||||
{
|
||||
"id": "parallel_progress_threshold_cancellation",
|
||||
"first_completed": "T1",
|
||||
"query_receipts": [
|
||||
{
|
||||
"task_id": "T2",
|
||||
"status": "running",
|
||||
"progress": 68.0,
|
||||
"queried_at": 5.345
|
||||
},
|
||||
{
|
||||
"task_id": "T3",
|
||||
"status": "running",
|
||||
"progress": 34.0,
|
||||
"queried_at": 5.345
|
||||
}
|
||||
],
|
||||
"cancelled_ids": [
|
||||
"T3"
|
||||
],
|
||||
"completed_results": {
|
||||
"T1": {
|
||||
"async_mentions": 69,
|
||||
"bytes": 106193,
|
||||
"error_keyword_count": 20,
|
||||
"experiment_mentions": 37,
|
||||
"heading_count": 27,
|
||||
"input_path": "/Users/boj/book/ai-agent-book/book/chapter6.md",
|
||||
"input_sha256": "4184460334544d326bb7ad4db349b8c583790195d51efc28ef325284c41d7fdd",
|
||||
"job": "fast",
|
||||
"lines": 679
|
||||
},
|
||||
"T2": {
|
||||
"async_mentions": 69,
|
||||
"bytes": 106193,
|
||||
"error_keyword_count": 20,
|
||||
"experiment_mentions": 37,
|
||||
"heading_count": 27,
|
||||
"input_path": "/Users/boj/book/ai-agent-book/book/chapter6.md",
|
||||
"input_sha256": "4184460334544d326bb7ad4db349b8c583790195d51efc28ef325284c41d7fdd",
|
||||
"job": "mid",
|
||||
"lines": 679
|
||||
}
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"elapsed_seconds": 0.0,
|
||||
"source": "TASK",
|
||||
"text": "启动真实子进程任务 T1: `python analyze_fast.py` (速度 3%/逻辑秒)"
|
||||
},
|
||||
{
|
||||
"elapsed_seconds": 0.001,
|
||||
"source": "TASK",
|
||||
"text": "启动真实子进程任务 T2: `python analyze_mid.py` (速度 2%/逻辑秒)"
|
||||
},
|
||||
{
|
||||
"elapsed_seconds": 0.001,
|
||||
"source": "TASK",
|
||||
"text": "启动真实子进程任务 T3: `python analyze_slow.py` (速度 1%/逻辑秒)"
|
||||
},
|
||||
{
|
||||
"elapsed_seconds": 1.121,
|
||||
"source": "TASK",
|
||||
"text": "T1 `python analyze_fast.py` 进度 21% (pid=92132)"
|
||||
},
|
||||
{
|
||||
"elapsed_seconds": 1.605,
|
||||
"source": "TASK",
|
||||
"text": "T2 `python analyze_mid.py` 进度 20% (pid=92133)"
|
||||
},
|
||||
{
|
||||
"elapsed_seconds": 2.219,
|
||||
"source": "TASK",
|
||||
"text": "T1 `python analyze_fast.py` 进度 42% (pid=92132)"
|
||||
},
|
||||
{
|
||||
"elapsed_seconds": 3.133,
|
||||
"source": "TASK",
|
||||
"text": "T3 `python analyze_slow.py` 进度 20% (pid=92134)"
|
||||
},
|
||||
{
|
||||
"elapsed_seconds": 3.144,
|
||||
"source": "TASK",
|
||||
"text": "T1 `python analyze_fast.py` 进度 60% (pid=92132)"
|
||||
},
|
||||
{
|
||||
"elapsed_seconds": 3.145,
|
||||
"source": "TASK",
|
||||
"text": "T2 `python analyze_mid.py` 进度 40% (pid=92133)"
|
||||
},
|
||||
{
|
||||
"elapsed_seconds": 4.235,
|
||||
"source": "TASK",
|
||||
"text": "T1 `python analyze_fast.py` 进度 81% (pid=92132)"
|
||||
},
|
||||
{
|
||||
"elapsed_seconds": 4.699,
|
||||
"source": "TASK",
|
||||
"text": "T2 `python analyze_mid.py` 进度 60% (pid=92133)"
|
||||
},
|
||||
{
|
||||
"elapsed_seconds": 5.332,
|
||||
"source": "TASK",
|
||||
"text": "T1 `python analyze_fast.py` 进度 100% (pid=92132)"
|
||||
},
|
||||
{
|
||||
"elapsed_seconds": 5.345,
|
||||
"source": "TASK",
|
||||
"text": "T1 完成 ✅ (pid=92132, returncode=0)"
|
||||
},
|
||||
{
|
||||
"elapsed_seconds": 5.345,
|
||||
"source": "SYSTEM",
|
||||
"event": "async_result_injected",
|
||||
"task_id": "T1"
|
||||
},
|
||||
{
|
||||
"elapsed_seconds": 5.345,
|
||||
"source": "TOOL",
|
||||
"event": "query_task",
|
||||
"task_id": "T2",
|
||||
"progress": 68.0
|
||||
},
|
||||
{
|
||||
"elapsed_seconds": 5.345,
|
||||
"source": "TOOL",
|
||||
"event": "query_task",
|
||||
"task_id": "T3",
|
||||
"progress": 34.0
|
||||
},
|
||||
{
|
||||
"elapsed_seconds": 5.345,
|
||||
"source": "TOOL",
|
||||
"event": "cancel_task",
|
||||
"task_id": "T3",
|
||||
"progress": 34.0
|
||||
},
|
||||
{
|
||||
"elapsed_seconds": 5.347,
|
||||
"source": "TASK",
|
||||
"text": "T3 子进程已终止 🛑 (pid=92134, 进度 34%)"
|
||||
},
|
||||
{
|
||||
"elapsed_seconds": 6.254,
|
||||
"source": "TASK",
|
||||
"text": "T2 `python analyze_mid.py` 进度 80% (pid=92133)"
|
||||
},
|
||||
{
|
||||
"elapsed_seconds": 7.815,
|
||||
"source": "TASK",
|
||||
"text": "T2 `python analyze_mid.py` 进度 100% (pid=92133)"
|
||||
},
|
||||
{
|
||||
"elapsed_seconds": 7.829,
|
||||
"source": "TASK",
|
||||
"text": "T2 完成 ✅ (pid=92133, returncode=0)"
|
||||
},
|
||||
{
|
||||
"elapsed_seconds": 7.829,
|
||||
"source": "SYSTEM",
|
||||
"event": "async_result_injected",
|
||||
"task_id": "T2"
|
||||
}
|
||||
],
|
||||
"tasks": [
|
||||
{
|
||||
"task_id": "T1",
|
||||
"command": "python analyze_fast.py",
|
||||
"status": "completed",
|
||||
"progress": 100.0,
|
||||
"pid": 92132,
|
||||
"returncode": 0,
|
||||
"started_at": 1785360517.401463,
|
||||
"completed_at": 1785360522.7459211,
|
||||
"elapsed_seconds": 5.344,
|
||||
"stdout_sha256": "cc880975410f2730b3a60c08b446abc561361940b173aa8241ab22f3d23be937",
|
||||
"stderr_tail": "",
|
||||
"result": {
|
||||
"async_mentions": 69,
|
||||
"bytes": 106193,
|
||||
"error_keyword_count": 20,
|
||||
"experiment_mentions": 37,
|
||||
"heading_count": 27,
|
||||
"input_path": "/Users/boj/book/ai-agent-book/book/chapter6.md",
|
||||
"input_sha256": "4184460334544d326bb7ad4db349b8c583790195d51efc28ef325284c41d7fdd",
|
||||
"job": "fast",
|
||||
"lines": 679
|
||||
},
|
||||
"executable": {
|
||||
"mode": "real_subprocess",
|
||||
"shell": false,
|
||||
"worker": "/Users/boj/book/ai-agent-book/chapter6/async-agent/analysis_worker.py",
|
||||
"worker_sha256": "ab700e034f63e4b8f83dfb4a6156745b7ab7c9352750c63a900f2632acb134c7",
|
||||
"input": "/Users/boj/book/ai-agent-book/book/chapter6.md",
|
||||
"input_sha256": "4184460334544d326bb7ad4db349b8c583790195d51efc28ef325284c41d7fdd",
|
||||
"job": "fast",
|
||||
"rate_percent_per_logical_second": 3.0,
|
||||
"tick_real_seconds": 0.15,
|
||||
"argv_sha256": "7261f0de1acd2bbe527657a61c48b925528ac99524a349ac91d350581830780e",
|
||||
"pid": 92132,
|
||||
"returncode": 0,
|
||||
"stdout_sha256": "cc880975410f2730b3a60c08b446abc561361940b173aa8241ab22f3d23be937",
|
||||
"stdout_lines": 35,
|
||||
"stderr_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"elapsed_seconds": 5.344
|
||||
}
|
||||
},
|
||||
{
|
||||
"task_id": "T2",
|
||||
"command": "python analyze_mid.py",
|
||||
"status": "completed",
|
||||
"progress": 100.0,
|
||||
"pid": 92133,
|
||||
"returncode": 0,
|
||||
"started_at": 1785360517.4036,
|
||||
"completed_at": 1785360525.229522,
|
||||
"elapsed_seconds": 7.826,
|
||||
"stdout_sha256": "a4257b9e774792d2df2f286f29d683ac24d90a766da66069fc5c4654e89ed003",
|
||||
"stderr_tail": "",
|
||||
"result": {
|
||||
"async_mentions": 69,
|
||||
"bytes": 106193,
|
||||
"error_keyword_count": 20,
|
||||
"experiment_mentions": 37,
|
||||
"heading_count": 27,
|
||||
"input_path": "/Users/boj/book/ai-agent-book/book/chapter6.md",
|
||||
"input_sha256": "4184460334544d326bb7ad4db349b8c583790195d51efc28ef325284c41d7fdd",
|
||||
"job": "mid",
|
||||
"lines": 679
|
||||
},
|
||||
"executable": {
|
||||
"mode": "real_subprocess",
|
||||
"shell": false,
|
||||
"worker": "/Users/boj/book/ai-agent-book/chapter6/async-agent/analysis_worker.py",
|
||||
"worker_sha256": "ab700e034f63e4b8f83dfb4a6156745b7ab7c9352750c63a900f2632acb134c7",
|
||||
"input": "/Users/boj/book/ai-agent-book/book/chapter6.md",
|
||||
"input_sha256": "4184460334544d326bb7ad4db349b8c583790195d51efc28ef325284c41d7fdd",
|
||||
"job": "mid",
|
||||
"rate_percent_per_logical_second": 2.0,
|
||||
"tick_real_seconds": 0.15,
|
||||
"argv_sha256": "3f8854dc260b11c7d00cbf44f3770cbe6eaf45da0ca6677108a485acd69faf4f",
|
||||
"pid": 92133,
|
||||
"returncode": 0,
|
||||
"stdout_sha256": "a4257b9e774792d2df2f286f29d683ac24d90a766da66069fc5c4654e89ed003",
|
||||
"stdout_lines": 51,
|
||||
"stderr_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"elapsed_seconds": 7.826
|
||||
}
|
||||
},
|
||||
{
|
||||
"task_id": "T3",
|
||||
"command": "python analyze_slow.py",
|
||||
"status": "cancelled",
|
||||
"progress": 34.0,
|
||||
"pid": 92134,
|
||||
"returncode": -15,
|
||||
"started_at": 1785360517.4054961,
|
||||
"completed_at": 1785360522.74722,
|
||||
"elapsed_seconds": 5.342,
|
||||
"stdout_sha256": null,
|
||||
"stderr_tail": "",
|
||||
"result": null,
|
||||
"executable": {
|
||||
"mode": "real_subprocess",
|
||||
"shell": false,
|
||||
"worker": "/Users/boj/book/ai-agent-book/chapter6/async-agent/analysis_worker.py",
|
||||
"worker_sha256": "ab700e034f63e4b8f83dfb4a6156745b7ab7c9352750c63a900f2632acb134c7",
|
||||
"input": "/Users/boj/book/ai-agent-book/book/chapter6.md",
|
||||
"input_sha256": "4184460334544d326bb7ad4db349b8c583790195d51efc28ef325284c41d7fdd",
|
||||
"job": "slow",
|
||||
"rate_percent_per_logical_second": 1.0,
|
||||
"tick_real_seconds": 0.15,
|
||||
"argv_sha256": "45f0bfa2cdbd6f54216af52a0d35e71b53de6c9a67f2ddbee1b56594cb1a4f67",
|
||||
"pid": 92134,
|
||||
"returncode": -15,
|
||||
"cancelled": true,
|
||||
"elapsed_seconds": 5.342
|
||||
}
|
||||
}
|
||||
],
|
||||
"artifact": {
|
||||
"path": "/Users/boj/book/ai-agent-book/chapter6/async-agent/validation/experiment_6_2/real_subprocess_20260730T052500Z/artifacts/scenario_4_report.json",
|
||||
"bytes": 1071,
|
||||
"sha256": "7bc8f53d28a8ec058424beb24ee1fcf9e36dd6c8b01e16fbea8dcc8d5b5dead7"
|
||||
}
|
||||
}
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
{
|
||||
"id": "queued_batch_to_japanese_html",
|
||||
"instruction_times": [
|
||||
0.503,
|
||||
0.703
|
||||
],
|
||||
"batch": [
|
||||
{
|
||||
"type": "async.result",
|
||||
"task_id": "T1",
|
||||
"result_sha256": "8cc819c59b166b1f24f678fffc632ea401136984f3616c2b98c9278ea85a570f"
|
||||
},
|
||||
{
|
||||
"type": "user.input",
|
||||
"instruction": "記得最後用日語回覆"
|
||||
},
|
||||
{
|
||||
"type": "user.input",
|
||||
"instruction": "結果をHTMLウェブページに整理"
|
||||
}
|
||||
],
|
||||
"events": [
|
||||
{
|
||||
"elapsed_seconds": 0.0,
|
||||
"source": "TASK",
|
||||
"text": "启动真实子进程任务 T1: `python analyze_logs.py` (速度 4%/逻辑秒)"
|
||||
},
|
||||
{
|
||||
"elapsed_seconds": 0.0,
|
||||
"source": "TOOL",
|
||||
"event": "placeholder_returned",
|
||||
"task_id": "T1"
|
||||
},
|
||||
{
|
||||
"elapsed_seconds": 0.503,
|
||||
"source": "QUEUE",
|
||||
"event": "deferred_instruction",
|
||||
"instruction": "japanese"
|
||||
},
|
||||
{
|
||||
"elapsed_seconds": 0.703,
|
||||
"source": "QUEUE",
|
||||
"event": "deferred_instruction",
|
||||
"instruction": "html"
|
||||
},
|
||||
{
|
||||
"elapsed_seconds": 0.796,
|
||||
"source": "TASK",
|
||||
"text": "T1 `python analyze_logs.py` 进度 22% (pid=92027)"
|
||||
},
|
||||
{
|
||||
"elapsed_seconds": 1.42,
|
||||
"source": "TASK",
|
||||
"text": "T1 `python analyze_logs.py` 进度 40% (pid=92027)"
|
||||
},
|
||||
{
|
||||
"elapsed_seconds": 2.198,
|
||||
"source": "TASK",
|
||||
"text": "T1 `python analyze_logs.py` 进度 63% (pid=92027)"
|
||||
},
|
||||
{
|
||||
"elapsed_seconds": 2.817,
|
||||
"source": "TASK",
|
||||
"text": "T1 `python analyze_logs.py` 进度 81% (pid=92027)"
|
||||
},
|
||||
{
|
||||
"elapsed_seconds": 3.592,
|
||||
"source": "TASK",
|
||||
"text": "T1 `python analyze_logs.py` 进度 100% (pid=92027)"
|
||||
},
|
||||
{
|
||||
"elapsed_seconds": 3.599,
|
||||
"source": "TASK",
|
||||
"text": "T1 完成 ✅ (pid=92027, returncode=0)"
|
||||
},
|
||||
{
|
||||
"elapsed_seconds": 3.599,
|
||||
"source": "SYSTEM",
|
||||
"event": "batch_appended",
|
||||
"event_count": 3,
|
||||
"deferred_count": 2
|
||||
}
|
||||
],
|
||||
"artifact": {
|
||||
"path": "/Users/boj/book/ai-agent-book/chapter6/async-agent/validation/experiment_6_2/real_subprocess_20260730T052500Z/artifacts/scenario_2_report.html",
|
||||
"bytes": 257,
|
||||
"sha256": "e468c5a2c05f5fe74b2c41ca123676ab2f325fda41869a615545221b7d1c7a2f",
|
||||
"doctype": true,
|
||||
"lang_ja": true,
|
||||
"has_japanese": true
|
||||
},
|
||||
"tasks": [
|
||||
{
|
||||
"task_id": "T1",
|
||||
"command": "python analyze_logs.py",
|
||||
"status": "completed",
|
||||
"progress": 100.0,
|
||||
"pid": 92027,
|
||||
"returncode": 0,
|
||||
"started_at": 1785360509.363528,
|
||||
"completed_at": 1785360512.962622,
|
||||
"elapsed_seconds": 3.599,
|
||||
"stdout_sha256": "7da29da059869ca8f939dd6d0d4c4a299d9e02a53592773c71517971af545ebd",
|
||||
"stderr_tail": "",
|
||||
"result": {
|
||||
"async_mentions": 69,
|
||||
"bytes": 106193,
|
||||
"error_keyword_count": 20,
|
||||
"experiment_mentions": 37,
|
||||
"heading_count": 27,
|
||||
"input_path": "/Users/boj/book/ai-agent-book/book/chapter6.md",
|
||||
"input_sha256": "4184460334544d326bb7ad4db349b8c583790195d51efc28ef325284c41d7fdd",
|
||||
"job": "logs",
|
||||
"lines": 679
|
||||
},
|
||||
"executable": {
|
||||
"mode": "real_subprocess",
|
||||
"shell": false,
|
||||
"worker": "/Users/boj/book/ai-agent-book/chapter6/async-agent/analysis_worker.py",
|
||||
"worker_sha256": "ab700e034f63e4b8f83dfb4a6156745b7ab7c9352750c63a900f2632acb134c7",
|
||||
"input": "/Users/boj/book/ai-agent-book/book/chapter6.md",
|
||||
"input_sha256": "4184460334544d326bb7ad4db349b8c583790195d51efc28ef325284c41d7fdd",
|
||||
"job": "logs",
|
||||
"rate_percent_per_logical_second": 4.5,
|
||||
"tick_real_seconds": 0.15,
|
||||
"argv_sha256": "1c6aa3b4072446e8286dce865f45c6bb1c93733319bf7501adbb555bc6a7973c",
|
||||
"pid": 92027,
|
||||
"returncode": 0,
|
||||
"stdout_sha256": "7da29da059869ca8f939dd6d0d4c4a299d9e02a53592773c71517971af545ebd",
|
||||
"stdout_lines": 24,
|
||||
"stderr_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"elapsed_seconds": 3.599
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"experiment": "6-2",
|
||||
"campaign_id": "real_subprocess_20260730T052500Z",
|
||||
"generated_at": "2026-07-29T21:28:45.232118+00:00",
|
||||
"tick_real_seconds": 0.15,
|
||||
"elapsed_seconds": 19.504,
|
||||
"scenario_status": {
|
||||
"async_command_and_immediate_question": "recorded",
|
||||
"queued_batch_to_japanese_html": "recorded",
|
||||
"interrupt_terminates_and_recovers": "recorded",
|
||||
"parallel_progress_threshold_cancellation": "recorded"
|
||||
},
|
||||
"acceptance": {
|
||||
"status": "passed",
|
||||
"gates": {
|
||||
"exact_four_scenarios": true,
|
||||
"real_subprocess_receipts_only": true,
|
||||
"scenario_1_nonblocking_and_immediate_response": true,
|
||||
"scenario_2_deferred_events_batched_once": true,
|
||||
"scenario_2_japanese_html_artifact": true,
|
||||
"scenario_3_os_process_cancelled_then_recovered": true,
|
||||
"scenario_4_exact_rates_and_fast_first": true,
|
||||
"scenario_4_query_once_and_cancel_only_under_threshold": true,
|
||||
"scenario_4_integrated_report_hashed": true
|
||||
},
|
||||
"protocol_sha256": "536c1dc0a187b8795c92b2c11babbf37c54b29215a69226221722567ba867fca"
|
||||
},
|
||||
"status": "passed"
|
||||
}
|
||||
Reference in New Issue
Block a user