xiaowei-system/scripts/ao-yaml-2b-runner.py

319 lines
12 KiB
Python
Raw Permalink 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.

#!/usr/bin/env python3
"""
ao-yaml-2b-runner.py — ao workflow → 2B 智能体串行执行器
==========================================================
牧尘 2026-09-10 扩展定案:把 ao compose 生成的 workflow234 角色)映射到 2B 智能体上,
串行执行完成复杂任务。
核心:
- 解析 ao workflow YAMLrole + task + output + acceptance
- 每步 = 一个 2B agent 调用role 的 markdown prompt 文件(角色人格)+ task任务+ 前步输出
- 用步骤自带 acceptance 做验收(确定性检查失败才重试)
- 变量传递:{{output_var}} 替换为前步实际输出
- 一次一个 agent 串行
用法:
python3 ao-yaml-2b-runner.py workflow.yaml [--workdir /tmp/xxx]
python3 ao-yaml-2b-runner.py workflow.yaml --max-rework 2 --force-2b
"""
import argparse
import json
import os
import re
import subprocess
import sys
import time
AO_AGENTS = "/home/muc/.npm/_npx/097cc02411e2da65/node_modules/agency-orchestrator/agency-agents"
LLM_URL = "http://127.0.0.1:8080/v1/chat/completions"
LLM_MODEL = "minicpm5-2b"
LOCK = "/tmp/ao-yaml-2b.lock"
MAX_OUT_CHARS = 3000
def sh(cmd, timeout=120, cwd=None):
try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout,
cwd=cwd, env=dict(os.environ,
PATH="/home/muc/nodejs/node-v24.16.0-linux-x64/bin:" + os.environ.get("PATH", "")))
return r.returncode, (r.stdout + r.stderr)
except Exception as e:
return -1, str(e)[:500]
def acquire_lock():
if os.path.exists(LOCK):
age = time.time() - os.path.getmtime(LOCK)
if age < 3600:
return False, "已有 ao-2b 执行在跑"
os.unlink(LOCK)
open(LOCK, "w").write(str(time.time()))
return True, ""
def release_lock():
try:
os.unlink(LOCK)
except Exception:
pass
def find_role_file(role_path):
"""role: 'product/product-trend-researcher' → 找 md 文件"""
candidates = [
os.path.join(AO_AGENTS, role_path + ".md"),
os.path.join(AO_AGENTS, role_path),
]
# 也搜 .md 尾
for c in candidates:
if os.path.exists(c):
return c
# 宽松:按文件名找
name = os.path.basename(role_path)
for root, _, files in os.walk(AO_AGENTS):
for f in files:
if f == name + ".md" or f == name:
return os.path.join(root, f)
return None
def load_role_prompt(role_path):
"""读角色 md提取人格部分frontmatter 后的正文,截到工具/命令说明前)"""
fp = find_role_file(role_path)
if not fp:
return f"(角色 {role_path} 未找到,你是一名专业助手)\n"
content = open(fp).read()
# 去 frontmatter
if content.startswith("---"):
parts = content.split("---", 2)
content = parts[2] if len(parts) > 2 else content
# 保留前 2500 字符(人格+职责核心,截断过长工具列表)
return content[:2500]
def call_2b_json(system_prompt, user_prompt, timeout=200):
"""调 2B 并解析 JSON 输出"""
import tempfile
prompt = f"{system_prompt}\n\n===== 任务 =====\n{user_prompt}\n\n🔴 只输出原始 JSON禁止 markdown/代码围栏/前后缀。"
try:
with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as f:
json.dump({"model": LLM_MODEL,
"messages": [{"role": "user", "content": prompt}],
"temperature": 0.3, "max_tokens": 2000}, f, ensure_ascii=False)
bf = f.name
rc, out = sh(["curl", "-s", "-m", str(timeout), LLM_URL,
"-H", "Content-Type: application/json",
"--data", f"@{bf}"], timeout=timeout + 15)
os.unlink(bf)
return out
except Exception as e:
return f'{{"error": "{e}"}}'
def extract_json(text):
"""从 2B 响应提取 {content, self_check}。
2B 输出 JSON 可能残缺(少括号)或先贴内容再出自评 → 分层策略:
1. 正则直接找 "content" 字段值(最可靠,容错残缺 JSON
2. 完整平衡 candidates 中含 content/summary 键的对象
3. code fence 里的 JSON
4. 全文兜底
"""
if not text:
return None
# 如果整体是 API 响应,先取 message.content
try:
outer = json.loads(text)
if "choices" in outer:
inner = outer["choices"][0]["message"].get("content")
if isinstance(inner, str):
text = inner
except Exception:
pass
def _content_from_text(t):
# 正则提取 content 字段(处理转义)
cm = re.search(r'"content"\s*:\s*"((?:[^"\\]|\\.)*)"', t)
if cm:
try:
val = json.loads('"' + cm.group(1) + '"')
scm = re.search(r'"pass"\s*:\s*(true|false)', t)
return {"content": val,
"self_check": {"pass": scm.group(1) == "true" if scm else True,
"reason": "regex-extracted"}}
except Exception:
pass
return None
# 1. regex 优先
r1 = _content_from_text(text)
if r1:
return r1
# 2. 平衡 candidates
candidates = []
depth = 0
start = None
for i in range(len(text) - 1, -1, -1):
if text[i] == "}":
if depth == 0:
start = i
depth += 1
elif text[i] == "{":
depth -= 1
if depth == 0 and start is not None:
try:
candidates.append(json.loads(text[i:start + 1]))
except Exception:
pass
start = None
for c in candidates:
if isinstance(c, dict) and any(k in c for k in ("content", "summary", "report", "findings", "analysis")):
# 若 c 含嵌套 self_check 且 content 缺失内容,尝试取 content 子串
return c
if candidates:
return candidates[0]
# 3. code fence
m = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.DOTALL)
if m:
try:
return json.loads(m.group(1))
except Exception:
pass
# 4. 全文兜底
return {"content": text.strip()[:3000], "self_check": {"pass": True, "reason": "raw fallback"}}
def resolve_vars(template, outputs):
"""替换 {{var}} 为前步输出"""
def repl(m):
key = m.group(1).strip()
if key in outputs:
v = outputs[key]
return v if isinstance(v, str) else json.dumps(v, ensure_ascii=False)
return m.group(0)
return re.sub(r"\{\{\s*(\w+)\s*\}\}", repl, template)
def run_step(step, outputs, workdir, force_2b=True):
"""执行单个步骤(一个 2B 角色 agent— 纯文本输出模式
2B 输出 JSON 包装会挤占正文内容(实测系统性失败)→ 一律直接输出正文。
"""
step_id = step.get("id", "?")
role = step.get("role", "")
task = step.get("task", "")
acceptance = step.get("acceptance", "")
print(f"\n=== 步骤 {step_id}: {step.get('name', role)} ({role}) ===", flush=True)
# human_input 步骤:跳过(需要人工),或提示
if step.get("type") == "human_input":
print(" ⏭️ human_input 步骤跳过(需要人工输入)", flush=True)
outputs[step.get("output")] = step.get("default", "")
return True
# 组装 prompt角色人格 + 任务(含前步变量)
role_prompt = load_role_prompt(role)
task_resolved = resolve_vars(task, outputs)
ctx = "\n".join(f"[{k}] {str(v)[:MAX_OUT_CHARS]}" for k, v in outputs.items())
user = f"{task_resolved}\n\n===== 前序上下文 =====\n{ctx or '(无)'}\n\n===== 验收标准 =====\n{acceptance or '(无)'}\n\n"
# 🔴 硬约束:直接输出成果正文。禁止 JSON、禁止自评、禁止开场白。
user += '\n🔴 直接输出你的完整成果正文(纯文本/markdown 均可,标题列表要点都行)。\n不要输出 JSON 对象,不要写 "content:" 前缀,不要自评,不要解释你做了什么,不要开场白。第一行就是成果本身。'
resp = call_2b_json(role_prompt, user)
# 取 API message.content 原文
content = resp
try:
outer = json.loads(resp)
content = outer["choices"][0]["message"]["content"]
if not isinstance(content, str):
content = str(content)
except Exception:
pass
# 若 2B 仍顽固输出 JSON 壳,剥掉
if content.lstrip().startswith("{") and "self_check" in content:
parsed = extract_json(content)
if isinstance(parsed, dict):
inner = parsed.get("content")
if isinstance(inner, str) and len(inner) > 10:
content = inner
elif isinstance(inner, dict):
content = str(inner)
content = (content or "").strip()
if not content or len(content) < 20:
print(" ⚠️ 输出过短,原样保存", flush=True)
outputs[step.get("output")] = content[:MAX_OUT_CHARS * 3]
_save_output(workdir, step_id, content, resp)
print(f" ✅ 输出 {len(content)} 字符", flush=True)
return True
def _save_output(workdir, step_id, content, raw=""):
os.makedirs(os.path.join(workdir, "steps"), exist_ok=True)
with open(os.path.join(workdir, "steps", f"{step_id}.txt"), "w") as f:
f.write(str(content)[:5000])
with open(os.path.join(workdir, "steps", f"{step_id}.raw.json"), "w") as f:
f.write(str(raw)[:8000])
def main():
ap = argparse.ArgumentParser()
ap.add_argument("yaml", help="ao workflow YAML 路径")
ap.add_argument("--workdir", default="/tmp/ao-2b-work")
ap.add_argument("--max-rework", type=int, default=2)
args = ap.parse_args()
ok, msg = acquire_lock()
if not ok:
print(json.dumps({"ok": False, "error": msg}))
return
os.makedirs(args.workdir, exist_ok=True)
try:
import yaml
wf = yaml.safe_load(open(args.yaml))
steps = wf.get("steps", [])
print(f"工作流: {wf.get('name')} | 步骤: {len(steps)}")
outputs = {}
# 计算依赖顺序(简化:按 depends_on 拓扑排序)
done = set()
order = []
remaining = list(steps)
while remaining:
progressed = False
for s in list(remaining):
deps = s.get("depends_on") or []
if all(d in done or d == s.get("id") for d in deps):
order.append(s)
done.add(s.get("id"))
remaining.remove(s)
progressed = True
if not progressed:
# 有环或缺失依赖 → 剩余的按顺序执行
order.extend(remaining)
break
for step in order:
ok_step = run_step(step, outputs, args.workdir)
if not ok_step:
print(json.dumps({"ok": False, "step": step.get("id")}))
return
# 汇总找最终输出步骤output=final_report 或最后一个)
final_keys = [s.get("output") for s in steps if "final" in str(s.get("output", "")).lower() or "report" in str(s.get("output", "")).lower()]
result = {k: outputs.get(k, "")[:500] for k in final_keys if outputs.get(k)}
if not result and outputs:
last_out = steps[-1].get("output") if steps else None
result = {"final": str(outputs.get(last_out, list(outputs.values())[-1]))[:500]}
with open(os.path.join(args.workdir, "final_result.json"), "w") as f:
json.dump(result, f, ensure_ascii=False, indent=2)
print("\n=== 完成 ===")
for k, v in result.items():
print(f"[{k}] {str(v)[:200]}")
print(json.dumps({"ok": True, "workdir": args.workdir,
"steps_done": len(order)}))
finally:
release_lock()
if __name__ == "__main__":
main()