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

This commit is contained in:
2026-08-20 13:12:50 +00:00
commit b119135836
10275 changed files with 3284984 additions and 0 deletions
+376
View File
@@ -0,0 +1,376 @@
"""
demo.py —— 实验 10-1 演示入口:多角色转换 / transfer_to_agent
最简运行(一条命令,跑默认复合任务):
python demo.py
其它常用方式:
python demo.py --list-roles # 离线:只打印角色花名册后退出(无需 API Key)
python demo.py --scenario coding # 换一个内置场景(会路由到 coding 角色)
python demo.py --task "..." # 自定义任务
python demo.py --role research # 指定起始角色(默认 triage 前台分诊)
python demo.py --interactive # 交互式多轮对话(角色与共享历史跨轮保留)
python demo.py --model gpt-5.6-luna --max-steps 30
演示一个需要【多次跨领域切换】的复合任务,预期出现
triage → research → data_analysis → writing
的自主移交链——每次移交都由当前角色自己判断并调用 transfer_to_agent 触发。
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from datetime import datetime, timezone
from pathlib import Path
from openai import OpenAI
from roles import ROLES, DEFAULT_ROLE
from orchestrator import MultiRoleOrchestrator, C
def _to_openrouter_model(model: str) -> str:
"""把模型名映射到 OpenRouter 命名空间(用于无 OPENAI_API_KEY 的回退路径)。"""
if "/" in model:
return model # 已是 OpenRouter 命名空间,原样使用
if model.startswith("gpt-"):
return "openai/" + model # gpt-* -> openai/gpt-*
if model.startswith("claude-"):
return "anthropic/claude-opus-4.8"
return "openai/gpt-5.6-luna" # 兜底:当前便宜旗舰
# 尽量读取 .env(可选依赖,没装也能跑,只要 shell 里已 export
try:
from dotenv import load_dotenv
load_dotenv()
except ImportError:
pass
# ---------------------------------------------------------------------------
# 内置场景:每个都刻意跨多个领域,以逼出多次自主移交。
# 键名用于 --scenario;值为 (任务文本, 一句话说明)。
# ---------------------------------------------------------------------------
COMPOSITE_TASK = (
"我在准备一份给投资人看的材料。请帮我:\n"
"1) 查一下中国 2021、2022、2023 三年的新能源汽车销量;\n"
"2) 据此算出这三年的年均复合增长率(CAGR);\n"
"3) 把数据和这个增长率结论,写成一段面向投资人的、不超过 120 字的中文总结。"
)
SCENARIOS: dict[str, tuple[str, str]] = {
"cagr": (
COMPOSITE_TASK,
"默认场景。跨检索/计算/写作三领域:查销量 → 算 CAGR → 写投资总结,"
"预期链路 triage → research → data_analysis → writing。",
),
"solar": (
"帮我查一下中国 2021、2022、2023 三年的光伏新增装机量,"
"算出这三年的年均复合增长率(CAGR),再写成一句话面向读者的结论。",
"另一组数据的同类链路(research → data_analysis → writing),验证机制而非记住答案。",
),
"coding": (
"请写一个 Python 脚本:计算斐波那契数列前 20 项,并求它们的和;"
"运行脚本得到结果后,用一句话向非技术读者解释这个结果。",
"路由到 coding 角色用 execute_python 真正跑代码,再由 writing/triage 收尾。",
),
}
DEFAULT_SCENARIO = "cagr"
def print_roster():
"""打印角色花名册,证明存在 5 个角色、各有不同系统提示词/工具集。"""
print(f"{C.BOLD}=== 角色花名册(共 {len(ROLES)} 个专业角色)==={C.RESET}")
for name, role in ROLES.items():
default_tag = "(默认入口)" if name == DEFAULT_ROLE else ""
tools = role.tools + ["transfer_to_agent"]
first_line = role.system_prompt.strip().splitlines()[0]
print(
f"{C.CYAN}{name}{C.RESET}{role.title}{default_tag}\n"
f" 工具集: {tools}\n"
f" 系统提示词(首句): {first_line}"
)
print()
def print_scenarios():
"""打印内置场景列表(供 --help / --list-roles 参考)。"""
print(f"{C.BOLD}=== 内置场景(--scenario==={C.RESET}")
for key, (_task, desc) in SCENARIOS.items():
default_tag = "(默认)" if key == DEFAULT_SCENARIO else ""
print(f"{C.CYAN}{key}{C.RESET}{default_tag}{desc}")
print()
def parse_args() -> argparse.Namespace:
"""命令行参数——均为可选,不传时行为与最初版本完全一致(跑默认复合任务)。"""
parser = argparse.ArgumentParser(
prog="demo.py",
formatter_class=argparse.RawDescriptionHelpFormatter,
description=(
"实验 10-1 演示:多角色转换 / transfer_to_agent。\n"
"在一段【共享对话历史】上,5 个专业角色通过 transfer_to_agent 自主接力,"
"触发形如 triage → research → data_analysis → writing 的移交链。"
),
epilog=(
"示例:\n"
" python demo.py # 跑默认场景(新能源汽车 CAGR 投资总结)\n"
" python demo.py --list-roles # 离线:只看角色/场景清单,不调用 API\n"
" python demo.py --scenario coding # 换到会路由至 coding 角色的场景\n"
" python demo.py --task '帮我...' # 自定义任务\n"
" python demo.py --role research # 从 research 角色起步\n"
" python demo.py --interactive # 交互式多轮,角色与共享历史跨轮保留\n"
),
)
parser.add_argument(
"--scenario",
choices=list(SCENARIOS.keys()),
default=DEFAULT_SCENARIO,
help=f"选择一个内置场景(默认 {DEFAULT_SCENARIO});被 --task 覆盖。可选:{list(SCENARIOS.keys())}",
)
parser.add_argument(
"--task",
default=None,
help="自定义任务文本,覆盖 --scenario;不传则使用所选内置场景。",
)
parser.add_argument(
"--role",
"--starting-role",
dest="role",
choices=list(ROLES.keys()),
default=DEFAULT_ROLE,
help=f"指定起始角色(默认 {DEFAULT_ROLE} 前台分诊)。可选:{list(ROLES.keys())}",
)
parser.add_argument(
"--interactive",
action="store_true",
help="交互式多轮模式:复用同一编排器,角色与共享历史跨轮保留(Ctrl-C / 输入 exit 退出)。",
)
parser.add_argument(
"--model",
default=None,
help="覆盖 OPENAI_MODEL 环境变量(默认沿用环境变量,未设置则为 gpt-5.6-luna)。",
)
parser.add_argument(
"--max-steps",
type=int,
default=20,
help="单条用户消息的最大 LLM 轮数硬上限,防止死循环(默认 20)。",
)
parser.add_argument(
"--list-roles",
action="store_true",
help="离线打印角色花名册与内置场景后退出,不需要 API Key(用于自检)。",
)
parser.add_argument(
"--output",
type=Path,
default=None,
help="保存完整、脱敏的机器可读实验轨迹与验收结论。",
)
return parser.parse_args()
def print_run_summary(orch: MultiRoleOrchestrator, final: str):
"""打印一次运行的移交链、分工总览与最终成果。"""
print(f"\n{C.BOLD}================ 运行汇总 ================{C.RESET}")
print(f"{C.MAGENTA}自主移交链:{C.RESET} {orch.handoff_chain_str()}")
print(f"{C.MAGENTA}移交次数:{C.RESET} {len(orch.handoffs)}")
for i, h in enumerate(orch.handoffs, 1):
print(f" {i}. {h.from_role}{h.to_role} | reason: {h.reason}")
print(f"\n{C.MAGENTA}各角色分工(谁用了什么工具、谁产出最终回复):{C.RESET}")
print(orch.role_work_summary())
print(f"\n{C.GREEN}最终成果:{C.RESET}\n{final}")
def save_evidence(
path: Path,
orch: MultiRoleOrchestrator,
final: str,
*,
model: str,
base_url: str,
task: str,
) -> dict:
"""Persist direct receipts and fail-closed manuscript acceptance gates."""
tools_by_role: dict[str, list[str]] = {}
for role, kind, detail in orch.activity:
if kind == "tool":
tools_by_role.setdefault(role, []).append(detail)
tool_contents = [
str(message.get("content", ""))
for message in orch.history
if message.get("role") == "tool"
]
real_search = any(
'"provider": "tavily"' in content and '"url":' in content
for content in tool_contents
)
counted_drafts: list[str] = []
for message in orch.history:
if message.get("role") != "assistant":
continue
for call in message.get("tool_calls") or []:
if call.get("function", {}).get("name") != "count_characters":
continue
try:
arguments = json.loads(call["function"].get("arguments") or "{}")
except json.JSONDecodeError:
arguments = {}
if isinstance(arguments.get("text"), str):
counted_drafts.append(arguments["text"])
final_draft = counted_drafts[-1] if counted_drafts else ""
chain = [orch.handoffs[0].from_role] + [h.to_role for h in orch.handoffs] if orch.handoffs else []
required_roles_in_order = all(
role in chain and chain.index(role) < chain.index(next_role)
for role, next_role in zip(
["triage", "research", "data_analysis"],
["research", "data_analysis", "writing"],
)
)
final_chars = len(final)
gates = {
"real_web_search_with_urls": real_search,
"triage_research_analysis_writing_order": required_roles_in_order,
"research_used_web_search": "web_search" in tools_by_role.get("research", []),
"data_analysis_used_calculate": "calculate" in tools_by_role.get("data_analysis", []),
"writing_checked_length": "count_characters" in tools_by_role.get("writing", []),
"final_not_step_limit": not orch.terminated_by_limit,
"final_nonempty": bool(final.strip()),
"investor_summary_within_120_characters": bool(final_draft) and len(final_draft) <= 120,
"shared_history_visible_after_handoffs": all(
later["history_messages_visible"] >= earlier["history_messages_visible"]
for earlier, later in zip(orch.api_calls, orch.api_calls[1:])
),
}
payload = {
"schema_version": "1.0",
"experiment": "10-1",
"generated_at_utc": datetime.now(timezone.utc).isoformat(),
"provider": {
"model": model,
"base_url": base_url,
"search": "Tavily",
"credentials_redacted": True,
},
"task": task,
"handoffs": [vars(h) for h in orch.handoffs],
"handoff_chain": chain,
"activity": [
{"role": role, "kind": kind, "detail": detail}
for role, kind, detail in orch.activity
],
"api_calls": orch.api_calls,
"steps_used": orch.steps_used,
"history": orch.history,
"final_answer": final,
"final_character_count": final_chars,
"counted_investor_summary": final_draft,
"counted_investor_summary_characters": len(final_draft),
"acceptance_gates": gates,
"status": "complete" if all(gates.values()) else "incomplete",
}
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"\n机器可读证据: {path} status={payload['status']}")
return payload
def run_interactive(orch: MultiRoleOrchestrator):
"""交互式多轮:同一编排器跨轮复用,共享历史与当前角色持续保留。"""
print(
f"{C.BOLD}=== 交互式多轮模式 ==={C.RESET}\n"
f"{C.DIM}输入你的请求后回车;输入 exit / quit 或按 Ctrl-C 退出。"
f"角色与对话历史会跨轮保留(共享上下文)。{C.RESET}"
)
turn = 0
while True:
try:
user_message = input(f"\n{C.BOLD}👤 你(当前控制权在 {orch.current_role}> {C.RESET}").strip()
except (EOFError, KeyboardInterrupt):
print("\n已退出交互模式。")
break
if not user_message:
continue
if user_message.lower() in {"exit", "quit", "q"}:
print("已退出交互模式。")
break
turn += 1
final = orch.run(user_message)
print_run_summary(orch, final)
def main():
args = parse_args()
# ---- 离线自检路径:无需 API Key ----
if args.list_roles:
print_roster()
print_scenarios()
return
model = args.model or os.environ.get("OPENAI_MODEL", "gpt-5.6-luna")
# 通用回退:优先直连 OPENAI_API_KEY;否则用 OPENROUTER_API_KEY 走 OpenRouter
# 都没有则报清晰错误。
# 特例:gpt-5.x 系列直连 OpenAI 需组织验证,且其 /v1/chat/completions 对带工具的
# 推理模型支持受限(reasoning_effort 限制)。因此只要设置了 OPENROUTER_API_KEY
# 就对 gpt-5.x 优先改走 OpenRouter,避免直连报错。
prefer_openrouter = model.startswith("gpt-5") and os.environ.get("OPENROUTER_API_KEY")
api_key = None if prefer_openrouter else os.environ.get("OPENAI_API_KEY")
if api_key:
base_url = os.environ.get("OPENAI_BASE_URL", "https://api.openai.com/v1")
elif os.environ.get("OPENROUTER_API_KEY"):
api_key = os.environ["OPENROUTER_API_KEY"]
base_url = "https://openrouter.ai/api/v1"
model = _to_openrouter_model(model)
why = "gpt-5.x 优先走 OpenRouter" if prefer_openrouter else "未检测到 OPENAI_API_KEY"
print(f"{why},改用 OpenRouter;模型映射为 {model}")
else:
print("错误:未找到环境变量 OPENAI_API_KEY 或 OPENROUTER_API_KEY。请先设置后重试。",
file=sys.stderr)
print("(提示:只想看角色/场景清单可运行 `python demo.py --list-roles`,无需 Key。)",
file=sys.stderr)
sys.exit(1)
client = OpenAI(api_key=api_key, base_url=base_url)
print_roster()
orch = MultiRoleOrchestrator(
client=client,
model=model,
max_steps=args.max_steps,
verbose=True,
start_role=args.role,
)
if args.interactive:
print(f"{C.BOLD}=== 模型 model={model},起始角色 {args.role} ==={C.RESET}")
run_interactive(orch)
return
# ---- 脚本化:单条复合任务,端到端跑完一次 ----
task = args.task if args.task is not None else SCENARIOS[args.scenario][0]
scenario_tag = "自定义任务" if args.task is not None else f"场景 {args.scenario}"
print(f"{C.BOLD}=== 开始执行({scenario_tag}model={model},起始角色={args.role}==={C.RESET}")
final = orch.run(task)
print_run_summary(orch, final)
if args.output:
save_evidence(
args.output,
orch,
final,
model=model,
base_url=base_url,
task=task,
)
if __name__ == "__main__":
main()