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,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
|
||||
Reference in New Issue
Block a user