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

387 lines
18 KiB
Python
Raw Normal View History

# -*- coding: utf-8 -*-
"""
demo_task.py — Pi Agent 兜底能力 P0 PoC:演示任务驱动脚本
==========================================================
两种模式:
- 默认(mock 模式,无需 LLM):准备一个真实订单 CSV(demo-data/锐扬APS演示数据/
05_orders.csv)拷入 runs/<runId>/inbox/;生成《执行计划草稿》→ 模拟确认 →
用**纯 Python 的 mock executor**(产出与真实 pi 相同结构的事件流,因此走编排器
同一条熔断/日志/stopReason 判定路径)模拟 Pi 的步骤:读 inbox 数据 → 分析 →
写报告到 outbox(报告引用 bridge 签发的 callId 凭证)。随后跑 3 项围墙自检:
熔断(步数闸)/ 凭证(伪造 callId)/ 圈禁(路径越界)。
- `--real`:走真实 Pi headless 路径(Agent-C 用):
`node runtime/.../cli.js -p --mode json --model <m> --tools read,grep,find,ls
-e <守卫扩展> "任务"`。无真实 LLM 网关时预期 stopReason=error 显式失败
(这正是本 PoC 要证明的「失败不包装成成功」)。
用法(Git Bash,工作区根目录):
.venv/Scripts/python.exe poc/pi-fallback/demo_task.py # mock 全流程 + 自检
.venv/Scripts/python.exe poc/pi-fallback/demo_task.py --real # 真实 pi(需模型网关)
.venv/Scripts/python.exe poc/pi-fallback/demo_task.py --real --model aps-local/qwen3-32b-local --timeout 120
"""
from __future__ import annotations
import argparse
import csv
import json
import random
import shutil
import sys
import time
from pathlib import Path
# 允许以脚本方式直接运行(poc/pi-fallback 加入 sys.path)
sys.path.insert(0, str(Path(__file__).resolve().parent))
from orchestrator import BreakerConfig, Orchestrator
from sandbox import (POC_ROOT, SandboxViolation, create_run_dirs, new_run_id,
validate_path, write_guard_extension)
from tool_bridge import ToolBridge, ToolBridgeViolation
# ---------------------------------------------------------------------------
# 集中配置
# ---------------------------------------------------------------------------
WORKSPACE_ROOT = POC_ROOT.parent.parent # aps-agent 工作区根
DEMO_CSV = WORKSPACE_ROOT / "demo-data" / "锐扬APS演示数据" / "05_orders.csv"
FALLBACK_ROWS = 100 # 找不到素材时生成的模拟工单行数(明确标注模拟数据)
REPORT_NAME = "report.md" # 产物唯一出口文件名(outbox/)
PLAN_NAME = "执行计划草稿.md"
# ---------------------------------------------------------------------------
# 素材准备
# ---------------------------------------------------------------------------
def prepare_inbox(inbox: Path) -> tuple[Path, str]:
"""
把数据分析素材拷入 inbox(L2 可读注入区)。
返回 (素材路径, 来源说明)。找不到真实素材时生成 100 行模拟工单数据并明确标注。
"""
if DEMO_CSV.exists():
dst = inbox / DEMO_CSV.name
shutil.copy2(DEMO_CSV, dst)
return dst, f"真实演示数据(拷贝自 {DEMO_CSV})"
# 兜底:生成模拟工单数据
dst = inbox / "simulated_workorders.csv"
rng = random.Random(42)
with open(dst, "w", newline="", encoding="utf-8-sig") as f:
w = csv.writer(f)
w.writerow(["woNo", "orderNo", "productCode", "quantity", "dueDate",
"priority", "status"])
for i in range(1, FALLBACK_ROWS + 1):
w.writerow([
f"WO-{i:04d}", f"SO-{rng.randint(1, 30):03d}",
f"P{rng.randint(1000, 9999)}", rng.randint(5, 200),
f"2026-08-{rng.randint(1, 28):02d}",
rng.randint(1, 6), rng.choice(["RELEASED", "IN_PROGRESS", "DONE"]),
])
return dst, "【模拟数据】未找到 demo-data 素材,脚本生成 100 行模拟工单"
# ---------------------------------------------------------------------------
# mock executor:模拟 Pi 会做的步骤(读 inbox → 分析 → 写 outbox 报告)
# ---------------------------------------------------------------------------
def make_mock_runner(run_id: str, run_dir: Path, inbox_csv: Path,
data_source_note: str, bridge: ToolBridge):
"""
构造 mock AgentRunner。事件结构与真实 pi headless JSONL 一致
(agent_start → tool_execution_start/end × N → message_end(stopReason) → agent_end),
因此编排器的熔断/日志/stopReason 判定是同一条代码路径。
每个"工具调用"都经 ToolBridge 签发 callId 并落 calls.jsonl,
报告里的成果声明逐条附 [callId: ...] 凭证。
"""
outbox = run_dir / "outbox"
def runner(task: str, work_dir: Path):
yield {"type": "session", "id": run_id, "mode": "mock"}
yield {"type": "agent_start"}
yield {"type": "turn_start"}
evidence: list[tuple[str, str]] = [] # (成果声明, callId)
def step(tool: str, params: dict, do):
"""一次模拟工具调用:签发 callId → 发 start 事件 → 执行 → 发 end 事件 → 落账。"""
call_id = bridge.issue_call(tool, params)
yield {"type": "tool_execution_start", "toolName": tool,
"toolCallId": call_id, "input": params}
try:
result = do()
bridge.complete_call(call_id, result, ok=True)
except Exception as exc:
bridge.complete_call(call_id, exc, ok=False)
raise
yield {"type": "tool_execution_end", "toolName": tool,
"toolCallId": call_id, "isError": False}
return call_id, result
# -- 步骤 1:fs_read 读 inbox 数据 ----------------------------------
def do_read():
# 读路径也过 L2 校验(mock 侧自觉走围墙)
safe = validate_path(inbox_csv, run_dir, zone="inbox", must_exist=True)
with open(safe, encoding="utf-8-sig", newline="") as f:
return list(csv.DictReader(f))
cid_read, rows = yield from _wrap(step("fs_read", {"path": str(inbox_csv)}, do_read))
evidence.append((f"读取注入区数据 {inbox_csv.name},共 {len(rows)} 行", cid_read))
# -- 步骤 2:aps_query 做只读统计 -----------------------------------
def do_stats():
by_status: dict = {}
by_level: dict = {}
qty_total = 0
high_pri = 0
for r in rows:
by_status[r.get("status", "?")] = by_status.get(r.get("status", "?"), 0) + 1
lvl = r.get("customerLevel", "?")
if lvl:
by_level[lvl] = by_level.get(lvl, 0) + 1
try:
qty_total += int(r.get("quantity") or 0)
except ValueError:
pass
try:
if int(r.get("priority") or 0) >= 5:
high_pri += 1
except ValueError:
pass
return {"rows": len(rows), "by_status": by_status, "by_customer_level": by_level,
"qty_total": qty_total, "high_priority": high_pri}
cid_stats, stats = yield from _wrap(step("aps_query", {"sql_like": "select status,count(*) group by status"}, do_stats))
evidence.append((f"订单状态分布 {stats['by_status']}", cid_stats))
evidence.append((f"订单总量 {stats['qty_total']} 件,优先级>=5 的 {stats['high_priority']} 单", cid_stats))
# -- 步骤 3:fs_write + report_emit 写报告到 outbox ------------------
def do_write():
lines = [
"# Pi 兜底分析报告(mock 模式产物)",
"",
f"- runId: `{run_id}`",
f"- 任务: {task}",
f"- 数据来源: {data_source_note}",
"",
"## 成果声明(每条附 callId 凭证)",
"",
]
for claim, cid in evidence:
lines.append(f"- {claim} [callId: {cid}]")
lines += [
"",
"## 结论建议",
"",
"优先级 >= 5 的订单建议优先排产;B 级客户订单量大可关注交付风险。",
"(本报告由 mock executor 生成,用于验证编排/围墙/凭证链路,非真实 LLM 产出)",
]
target = validate_path(outbox / REPORT_NAME, run_dir, zone="outbox")
target.write_text("\n".join(lines) + "\n", encoding="utf-8")
return str(target)
cid_write, report_path = yield from _wrap(step("fs_write", {"path": str(outbox / REPORT_NAME)}, do_write))
yield from _wrap(step("report_emit", {"md": report_path}, lambda: report_path))
report_text = Path(report_path).read_text(encoding="utf-8")
# -- 收尾事件:stopReason=stop(编排器据此判成功) --------------------
assistant_msg = {
"role": "assistant", "stopReason": "stop",
"content": [{"type": "text", "text": report_text}],
}
yield {"type": "message_end", "message": assistant_msg}
yield {"type": "turn_end"}
yield {"type": "agent_end", "messages": [assistant_msg]}
# step() 是生成器函数包装:_wrap 让 "执行 + yield 事件" 能 return 值
return runner
def _wrap(gen):
"""驱动 step 生成器,透传 yield 的事件,取回其 return 值。"""
value = yield from gen
return value
# ---------------------------------------------------------------------------
# 围墙自检(mock 模式下验证熔断/凭证/圈禁各一条)
# ---------------------------------------------------------------------------
def self_checks() -> dict:
"""
3 项自检,对应 GOAL.md 的 3 个越狱测试判据:
1) 熔断:步数闸触发 → 运行显式 failed;
2) 凭证:报告引用伪造 callId → validate 判 invalid;
3) 圈禁:越界路径(../../server)→ validate_path 拒绝。
"""
results = {}
# 1) 熔断:步数上限
def runaway_runner(task, work_dir):
yield {"type": "agent_start"}
for i in range(20): # 远超 max_steps=3
yield {"type": "tool_execution_start", "toolName": "fs_read",
"toolCallId": f"runaway-{i}", "input": {}}
yield {"type": "tool_execution_end", "toolName": "fs_read",
"toolCallId": f"runaway-{i}", "isError": False}
yield {"type": "agent_end", "messages": []}
orch = Orchestrator(BreakerConfig(max_steps=3, timeout_sec=60))
r = orch.run("runaway self-check", runaway_runner, run_id=new_run_id("selfcheck-breaker"))
results["breaker_step_limit"] = {
"pass": (not r.success) and "max_steps" in r.stop_reason,
"detail": f"success={r.success} stop_reason={r.stop_reason} steps={r.steps}",
}
# 2) 凭证:伪造 callId 必须判 invalid
run_id = new_run_id("selfcheck-credential")
dirs = create_run_dirs(run_id)
bridge = ToolBridge(run_id, dirs["root"])
real_cid = bridge.issue_call("fs_read", {"path": "inbox/x.csv"})
bridge.complete_call(real_cid, "ok")
good = bridge.validate_report_citations(f"成果 A [callId: {real_cid}]")
forged = "call-" + "deadbeef-0000-0000-0000-000000000000"
bad = bridge.validate_report_citations(f"成果 B [callId: {forged}]")
results["credential_forgery"] = {
"pass": good["valid"] and (not bad["valid"]) and bad["missing"] == [forged],
"detail": f"真凭证 valid={good['valid']};伪造凭证 valid={bad['valid']} missing={bad['missing']}",
}
# 3) 圈禁:越界路径必须拒绝
run_id = new_run_id("selfcheck-sandbox")
dirs = create_run_dirs(run_id)
checks = []
try:
validate_path("../../server/evil.py", dirs["root"])
checks.append("越界未拦截!")
except SandboxViolation:
checks.append("越界已拦截")
try:
validate_path(dirs["outbox"] / "ok.md", dirs["root"], zone="outbox")
checks.append("界内放行")
except SandboxViolation as e:
checks.append(f"界内误杀: {e}")
results["sandbox_escape"] = {
"pass": checks == ["越界已拦截", "界内放行"],
"detail": "; ".join(checks),
}
return results
# ---------------------------------------------------------------------------
# 主流程
# ---------------------------------------------------------------------------
def run_mock(task: str) -> int:
"""mock 模式完整流程:建目录 → 备料 → 计划草稿 → 模拟确认 → 编排执行 → 凭证校验 → 自检。"""
run_id = new_run_id()
dirs = create_run_dirs(run_id)
run_dir = dirs["root"]
print(f"[demo] run_id={run_id}")
inbox_csv, source_note = prepare_inbox(dirs["inbox"])
print(f"[demo] inbox 素材: {inbox_csv.name}({source_note})")
# —— 产出《执行计划草稿》(方案 §4.4 agent.fallback.propose,沙盒语义不写主干)——
plan = f"""# 执行计划草稿(propose 阶段产物)
- runId: `{run_id}`
- 任务: {task}
- 数据来源: {source_note}
## 计划步骤(卡片上的承诺即执行上限)
| 步 | 工具 | 权力 | 动作 |
|----|------|------|------|
| 1 | fs_read | P0 | 读 inbox/{inbox_csv.name} |
| 2 | aps_query | P0 | 状态/优先级/客户等级只读统计 |
| 3 | fs_write | P1 | 写报告到 outbox/{REPORT_NAME} |
| 4 | report_emit | P1 | 报告走凭证校验出口 |
## 预计影响面
只读 inbox,仅写 outbox/{REPORT_NAME};不触主干数据;无需 checkpoint 回滚。
"""
(run_dir / PLAN_NAME).write_text(plan, encoding="utf-8")
# —— 模拟确认(产品态为 P2 确认卡;PoC 落一个确认记录文件)——
(run_dir / "确认记录.md").write_text(
f"# 模拟确认\n\nrunId: {run_id}\n计划已(模拟)人工确认,进入 execute 阶段。\n",
encoding="utf-8")
print(f"[demo] 《{PLAN_NAME}》已生成并模拟确认")
# —— 编排执行(mock executor 走编排器同一条熔断/日志/判定路径)——
bridge = ToolBridge(run_id, run_dir)
runner = make_mock_runner(run_id, run_dir, inbox_csv, source_note, bridge)
orch = Orchestrator(BreakerConfig()) # 默认三闸:10min / 50 步 / 10MiB
t0 = time.time()
res = orch.run(task, runner, run_id=run_id)
print(f"[demo] 编排完成: success={res.success} stop_reason={res.stop_reason} "
f"steps={res.steps} output={res.output_bytes}B elapsed={res.elapsed_sec:.2f}s")
# —— 成果校验:报告引用的 callId 必须全部真实存在(方案 §4.5)——
report_path = dirs["outbox"] / REPORT_NAME
if res.success and report_path.exists():
v = bridge.validate_report_citations(report_path.read_text(encoding="utf-8"))
print(f"[demo] 凭证校验: valid={v['valid']} cited={len(v['cited'])} "
f"issued={len(v['issued'])} missing={v['missing']}")
if not v["valid"]:
print("[demo] ⚠️ 报告含无凭证的成果声明 → 判 invalid", file=sys.stderr)
return 2
else:
print("[demo] ⚠️ 编排失败或无报告产物", file=sys.stderr)
return 2
# —— 围墙自检 ——
checks = self_checks()
all_pass = True
print("[demo] 围墙自检:")
for name, r in checks.items():
print(f" [{'PASS' if r['pass'] else 'FAIL'}] {name}: {r['detail']}")
all_pass = all_pass and r["pass"]
print(f"[demo] 运行目录: {run_dir}")
print(f"[demo] 报告产物: {report_path}")
print(f"[demo] 总耗时 {time.time() - t0:.1f}s,自检 {'全部通过' if all_pass else '存在失败'}")
return 0 if all_pass else 3
def run_real(task: str, model: str, timeout: float) -> int:
"""--real 模式:真实 pi headless。无 LLM 网关时预期 stopReason=error 显式失败。"""
run_id = new_run_id("real")
dirs = create_run_dirs(run_id)
inbox_csv, source_note = prepare_inbox(dirs["inbox"])
guard = write_guard_extension(dirs["root"])
print(f"[real] run_id={run_id} 素材={inbox_csv.name}({source_note})")
print(f"[real] 守卫扩展: {guard}")
orch = Orchestrator(BreakerConfig(timeout_sec=timeout))
runner = orch.pi_headless_runner(model=model, tools="read,grep,find,ls", guard_ext=guard)
full_task = (f"{task}\n数据文件在 inbox/{inbox_csv.name}(相对当前目录上一级),"
f"分析报告写到 outbox/{REPORT_NAME}。")
res = orch.run(full_task, runner, run_id=run_id)
print(f"[real] 结果: success={res.success} stop_reason={res.stop_reason} "
f"steps={res.steps} elapsed={res.elapsed_sec:.1f}s")
if res.error_message:
print(f"[real] error: {res.error_message[:300]}")
print(f"[real] 运行目录: {dirs['root']}(events.jsonl / orchestrator.log / result.json)")
# 显式失败也是成功交付:返回码只在 harness 自身错误时非零
return 0
def main() -> int:
ap = argparse.ArgumentParser(description="Pi 兜底 PoC 演示任务驱动")
ap.add_argument("--real", action="store_true", help="走真实 Pi headless(需模型网关)")
ap.add_argument("--model", default="aps-local/qwen3-32b-local", help="--real 用模型")
ap.add_argument("--timeout", type=float, default=600.0, help="超时秒数(默认 600)")
ap.add_argument("--task", default="分析注入区的订单数据:状态分布、优先级分布、客户等级结构,给出排产建议",
help="任务简报")
args = ap.parse_args()
if args.real:
return run_real(args.task, args.model, args.timeout)
return run_mock(args.task)
if __name__ == "__main__":
sys.exit(main())