aps-agent/poc/pi-fallback/orchestrator.py

365 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- coding: utf-8 -*-
"""
orchestrator.py — Pi Agent 兜底能力 P0 PoC:Python 编排器
=========================================================
职责(对应 `docs/architecture/fallback.md` 的 FallbackLane 编排与熔断设计):
1. 以子进程拉起 Pi headless(`node .../cli.js -p --mode json`),逐行解析 JSONL 事件流;
2. 三重熔断:超时 / 步数上限 / 输出体量上限,触发即杀进程树(Windows taskkill /T /F)
并把运行显式标记为 failed(绝不把失败包装成成功 —— GOAL.md 价值观);
3. 环境变量白名单清洗后传给子进程(剥离 CONDA_*/PYTHONHOME/PYTHONPATH 等),
并设置 PI_CODING_AGENT_DIR 指向 poc 内配置目录(NOTES-pi-runtime.md §3);
4. 成败判定基于事件流里的 stopReason("stop"=正常 / "error"=失败),
**绝不相信进程退出码**(pi headless 失败也退 0 —— Agent-A 实测坑 #1);
5. 全程写运行日志到 runs/<runId>/:events.jsonl(原始事件流)、orchestrator.log、result.json。
mock 模式说明:AgentRunner 是协议抽象,mock executor(demo_task.py)产出与真实
pi 相同结构的事件序列,因此熔断/日志/stopReason 判定走**同一条代码路径**。
"""
from __future__ import annotations
import json
import os
import queue
import subprocess
import threading
import time
import uuid
from dataclasses import dataclass, field
from pathlib import Path
from typing import Callable, Iterable, Iterator, Optional
from sandbox import POC_ROOT, RUNS_ROOT, PI_HOME, PI_CLI, create_run_dirs
# ---------------------------------------------------------------------------
# 集中配置(改行为只改这里)
# ---------------------------------------------------------------------------
@dataclass
class BreakerConfig:
"""三重熔断阈值(对应方案 §4.6「熔断三闸」)。"""
timeout_sec: float = 600.0 # 闸 1:单次运行超时(默认 10min,方案 §S10)
max_steps: int = 50 # 闸 2:工具调用步数上限(方案 §4.6 步数 50)
max_output_bytes: int = 10 * 1024 * 1024 # 闸 3:assistant 输出累计体量上限(10MiB)
poll_interval_sec: float = 1.0 # 读事件流的轮询间隔(也用于检测进程挂起)
# 子进程环境变量白名单(L4 进程层围墙,对应方案 §4.2 buildChildEnv 语义)
ENV_WHITELIST = (
"PATH", "PATHEXT", "SYSTEMROOT", "SYSTEMDRIVE", "WINDIR", "COMSPEC",
"TEMP", "TMP", "USERPROFILE", "APPDATA", "LOCALAPPDATA", "HOME",
"NODE_OPTIONS", # node 运行时可能需要;其余一律不传
)
# 显式剥离名单(即使同名出现在白名单也再剥一层,双保险)
ENV_STRIP_PREFIXES = ("CONDA_", "PYTHON", "PIP_", "VIRTUAL_ENV")
def build_child_env(extra: Optional[dict] = None) -> dict:
"""
构造传给 Pi 子进程的清洗后环境(白名单制)。
- 只放行 ENV_WHITELIST 里的变量;
- 剥离 CONDA_*/PYTHONHOME/PYTHONPATH/PIP_*/VIRTUAL_ENV*(防止 Anaconda 污染 node 子进程);
- 强制设置 PI_CODING_AGENT_DIR 指向 poc 内 runtime/pi-home(配置圈禁,Agent-A 实测机制)。
"""
env: dict = {}
for key in ENV_WHITELIST:
if key in os.environ and not key.upper().startswith(ENV_STRIP_PREFIXES):
env[key] = os.environ[key]
env["PI_CODING_AGENT_DIR"] = str(PI_HOME)
if extra:
for k, v in extra.items():
if not k.upper().startswith(ENV_STRIP_PREFIXES):
env[k] = v
return env
# ---------------------------------------------------------------------------
# 运行结果
# ---------------------------------------------------------------------------
@dataclass
class RunResult:
"""一次兜底运行的最终判定。success 只由 stopReason 决定。"""
run_id: str
success: bool
stop_reason: str = "" # "stop" / "error" / "breaker:<哪种熔断>" / "harness_error"
error_message: str = ""
steps: int = 0 # 工具调用步数(tool_execution_start 计数)
output_bytes: int = 0 # assistant 输出累计字节
elapsed_sec: float = 0.0
final_text: str = "" # 最后一条 assistant 消息文本(即 Pi 的报告)
run_dir: str = ""
extra: dict = field(default_factory=dict)
# ---------------------------------------------------------------------------
# 事件流来源抽象
# ---------------------------------------------------------------------------
# AgentRunner:给定 (task, work_dir, event_sink) 产出 JSONL 事件 dict 迭代器。
# 真实实现 = pi headless 子进程;mock 实现 = demo_task.py 的纯 Python executor。
AgentRunner = Callable[[str, Path], Iterator[dict]]
class Orchestrator:
"""编排器本体:消费事件流,执行熔断与判定,落日志。"""
def __init__(self, config: Optional[BreakerConfig] = None):
self.cfg = config or BreakerConfig()
# -- 主入口 -----------------------------------------------------------
def run(self, task: str, runner: AgentRunner, run_id: Optional[str] = None,
on_tool_event: Optional[Callable[[dict], None]] = None) -> RunResult:
"""
执行一次兜底运行。
参数:
task: 任务简报(自然语言)。
runner: AgentRunner,产出事件流(真实或 mock 共用本条熔断路径)。
run_id: 缺省自动生成;目录 runs/<run_id>/ 由 sandbox.create_run_dirs 建好。
on_tool_event: 每个 tool_execution_start/end 事件的回调(tool_bridge 借此签发 callId)。
返回 RunResult;任何熔断/错误都会显式写进 result.json 与 orchestrator.log。
"""
run_id = run_id or time.strftime("run-%Y%m%d-%H%M%S-") + uuid.uuid4().hex[:6]
dirs = create_run_dirs(run_id)
run_dir = dirs["root"]
log_path = run_dir / "orchestrator.log"
events_path = run_dir / "events.jsonl"
def log(msg: str) -> None:
line = f"[{time.strftime('%H:%M:%S')}] {msg}\n"
with open(log_path, "a", encoding="utf-8") as f:
f.write(line)
res = RunResult(run_id=run_id, success=False, run_dir=str(run_dir))
t0 = time.monotonic()
log(f"run_id={run_id} task={task[:120]!r}")
log(f"breaker config: {self.cfg}")
breaker_tripped: Optional[str] = None
last_assistant_text = ""
stop_reason = ""
error_message = ""
try:
with open(events_path, "w", encoding="utf-8") as evf:
for event in runner(task, dirs["work"]):
elapsed = time.monotonic() - t0
# —— 闸 1:超时 ——
if elapsed > self.cfg.timeout_sec:
breaker_tripped = f"breaker:timeout({elapsed:.1f}s>{self.cfg.timeout_sec}s)"
self._safe_write_event(evf, {"type": "breaker", "reason": breaker_tripped})
break
self._safe_write_event(evf, event)
etype = event.get("type", "")
# —— 步数统计 + 闸 2 ——
if etype == "tool_execution_start":
res.steps += 1
if on_tool_event:
on_tool_event(event)
if res.steps > self.cfg.max_steps:
breaker_tripped = f"breaker:max_steps({res.steps}>{self.cfg.max_steps})"
self._safe_write_event(evf, {"type": "breaker", "reason": breaker_tripped})
break
elif etype == "tool_execution_end" and on_tool_event:
on_tool_event(event)
# —— 输出体量统计 + 闸 3 ——
text_delta = self._extract_text_delta(event)
if text_delta:
res.output_bytes += len(text_delta.encode("utf-8"))
if res.output_bytes > self.cfg.max_output_bytes:
breaker_tripped = (
f"breaker:max_output({res.output_bytes}>{self.cfg.max_output_bytes})"
)
self._safe_write_event(evf, {"type": "breaker", "reason": breaker_tripped})
break
# —— stopReason 判定(成败唯一权威,Agent-A 坑 #1)——
sr, em, txt = self._extract_stop(event)
if sr:
stop_reason, error_message = sr, em
if txt:
last_assistant_text = txt
# 自动重试事件:只记录(Agent-A 坑 #4,重试 ~14s 已计入超时预算)
if etype == "auto_retry_start":
log(f"auto_retry_start attempt={event.get('attempt')}")
if etype == "agent_end":
break
except Exception as exc: # runner 自身抛错(如子进程 spawn 失败)
stop_reason = "harness_error"
error_message = f"{type(exc).__name__}: {exc}"
log(f"HARNESS ERROR: {error_message}")
res.elapsed_sec = time.monotonic() - t0
res.final_text = last_assistant_text
if breaker_tripped:
res.stop_reason = breaker_tripped
res.error_message = "熔断触发,运行显式标记失败"
log(f"BREAKER TRIPPED: {breaker_tripped} -> failed")
elif stop_reason == "stop":
res.success = True
res.stop_reason = "stop"
log(f"OK stopReason=stop steps={res.steps} out={res.output_bytes}B "
f"elapsed={res.elapsed_sec:.1f}s")
else:
res.stop_reason = stop_reason or "error:no_stop_reason"
res.error_message = error_message or "事件流未给出 stopReason=stop,按失败处理"
log(f"FAILED stopReason={res.stop_reason} err={res.error_message}")
with open(run_dir / "result.json", "w", encoding="utf-8") as f:
json.dump(res.__dict__, f, ensure_ascii=False, indent=2)
return res
# -- 事件解析辅助 ------------------------------------------------------
@staticmethod
def _safe_write_event(evf, event: dict) -> None:
try:
evf.write(json.dumps(event, ensure_ascii=False) + "\n")
except Exception:
evf.write(json.dumps({"type": "unserializable_event"}) + "\n")
@staticmethod
def _extract_text_delta(event: dict) -> str:
"""从 message_update/message_end 事件取 assistant 文本增量(用于输出体量闸)。"""
if event.get("type") == "message_update":
delta = event.get("delta") or {}
if isinstance(delta, dict):
return str(delta.get("text") or "")
if event.get("type") == "message_end":
msg = event.get("message") or {}
for part in (msg.get("content") or []):
if isinstance(part, dict) and part.get("type") == "text":
return str(part.get("text") or "")
return ""
@staticmethod
def _extract_stop(event: dict):
"""
从事件里提取 (stopReason, errorMessage, 文本)。
兼容两个位置:message_end.message.stopReason 与 agent_end.messages 最后一条。
"""
etype = event.get("type")
msg = None
if etype == "message_end":
msg = event.get("message") or {}
elif etype == "agent_end":
msgs = event.get("messages") or []
assistants = [m for m in msgs if m.get("role") == "assistant"]
msg = assistants[-1] if assistants else None
if not msg:
return None, None, None
txt = ""
for part in (msg.get("content") or []):
if isinstance(part, dict) and part.get("type") == "text":
txt += str(part.get("text") or "")
return msg.get("stopReason"), msg.get("errorMessage"), txt
# -- 真实 pi headless runner ------------------------------------------
def pi_headless_runner(self, model: str = "aps-local/qwen3-32b-local",
tools: str = "read,grep,find,ls",
guard_ext: Optional[Path] = None,
extra_args: Optional[list] = None) -> AgentRunner:
"""
构造真实 pi headless 的 AgentRunner(供 --real 模式,Agent-C 使用)。
命令形态(Agent-A 实测):
node <PI_CLI> -p --mode json --model <m> --tools <白名单> [-e guard.ts] "task"
- cwd 圈禁在 runs/<runId>/work(L2 文件层,pi 自身无沙箱);
- 环境经 build_child_env 清洗(L4);
- 子进程 stdout 由读线程转 queue,主循环带 poll 超时 —— 进程挂起也能被闸 1 抓到;
- 熔断/结束时 taskkill /T /F 杀整棵进程树。
"""
cmd_tail = extra_args or []
def runner(task: str, work_dir: Path) -> Iterator[dict]:
cmd = [
"node", str(PI_CLI),
"-p", "--mode", "json",
"--model", model,
"--tools", tools,
]
if guard_ext:
cmd += ["-e", str(guard_ext)]
cmd += cmd_tail + [task]
env = build_child_env()
proc = subprocess.Popen(
cmd, cwd=str(work_dir), env=env,
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP,
)
q: "queue.Queue[Optional[str]]" = queue.Queue()
def reader():
try:
for raw in proc.stdout:
q.put(raw.decode("utf-8", errors="replace"))
finally:
q.put(None) # EOF 哨兵
t = threading.Thread(target=reader, daemon=True)
t.start()
killed = False
try:
while True:
try:
line = q.get(timeout=self.cfg.poll_interval_sec)
except queue.Empty:
if proc.poll() is not None and t.is_alive() is False:
break
# 让上层熔断循环有机会检查超时:产出一个心跳事件
yield {"type": "harness_heartbeat"}
continue
if line is None:
break
line = line.strip()
if not line:
continue
try:
yield json.loads(line)
except json.JSONDecodeError:
yield {"type": "harness_nonjson", "raw": line[:500]}
finally:
# 进程回收(L4):编排器熔断 break 时会 close 本生成器,
# 这里只杀进程树、不许再 yield(closing 中的生成器 yield 会 RuntimeError)。
if proc.poll() is None:
killed = True
kill_process_tree(proc.pid)
try:
proc.wait(timeout=15)
except Exception:
pass
# 以下仅正常 EOF 退出时可达(被 close 时 GeneratorExit 会直接传播出去)
if killed:
yield {"type": "harness_killed", "pid": proc.pid}
err = proc.stderr.read().decode("utf-8", errors="replace") if proc.stderr else ""
if err.strip():
yield {"type": "harness_stderr", "text": err[-2000:]}
return runner
def kill_process_tree(pid: int) -> None:
"""Windows 下杀整棵进程树(node 起的孙进程一并回收,对应方案 §4.2 L4 进程回收)。"""
subprocess.run(
["taskkill", "/PID", str(pid), "/T", "/F"],
capture_output=True, timeout=30,
)