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
@@ -0,0 +1,313 @@
"""
orchestrator.py —— 多角色移交(handoff)编排器。
核心机制(实验 10-1):
- 全程维护一段【共享对话历史】historyuser/assistant/tool 消息)。
- 每次调用大模型时,把【当前角色】的系统提示词临时拼到 history 前面,
并只暴露【当前角色的工具集 + transfer_to_agent】。
- 模型可以:
1) 调用自己的专属工具(正常 function calling);
2) 调用 transfer_to_agent 把控制权移交给别的角色——
此时编排器换掉「系统提示词 + 工具集」,但 history 原样保留,
于是新角色天然继承了全部对话历史(共享上下文)。
- 循环直到某个角色给出「没有工具调用」的最终回复。
"""
from __future__ import annotations
import json
import time
from dataclasses import dataclass
from typing import Callable, Dict, List, Optional
from openai import OpenAI
from roles import ROLES, DEFAULT_ROLE, transfer_tool_schema
from tools import TOOL_SCHEMAS, TOOL_IMPLEMENTATIONS
# ---- 终端着色(无第三方依赖)----
class C:
RESET = "\033[0m"
DIM = "\033[2m"
BOLD = "\033[1m"
CYAN = "\033[36m"
GREEN = "\033[32m"
YELLOW = "\033[33m"
MAGENTA = "\033[35m"
BLUE = "\033[34m"
RED = "\033[31m"
@dataclass
class Handoff:
from_role: str
to_role: str
reason: str
class MultiRoleOrchestrator:
def __init__(
self,
client: OpenAI,
model: str = "gpt-5.6-luna",
max_steps: int = 20,
max_output_tokens: Optional[int] = None,
verbose: bool = True,
start_role: str = DEFAULT_ROLE,
provider_receipt_sink: Optional[Callable[[dict], None]] = None,
tool_receipt_sink: Optional[Callable[[dict], None]] = None,
):
if start_role not in ROLES:
raise ValueError(f"未知的起始角色 {start_role!r},可选:{list(ROLES.keys())}")
self.client = client
self.model = model
self.max_steps = max_steps
self.max_output_tokens = max_output_tokens
self.verbose = verbose
self.history: List[dict] = [] # 共享对话历史(不含 system
self.current_role: str = start_role # 当前控制权所在角色(可自定义起始角色)
self.handoffs: List[Handoff] = [] # 记录移交链
self._tool_call_counts: Dict[str, int] = {} # 相同工具调用去重计数(防死循环)
# 分工记录:(role, kind, detail)kind ∈ {"tool", "transfer", "final"}
# 用于运行结束后打印「哪个角色做了什么」的分工总览。
self.activity: List[tuple] = []
self.api_calls: List[dict] = []
self.steps_used: int = 0
self.terminated_by_limit: bool = False
self.provider_receipt_sink = provider_receipt_sink
self.tool_receipt_sink = tool_receipt_sink
# -------------------------------------------------------------- 工具装配
def _tools_for_current_role(self) -> List[dict]:
"""当前角色可见的工具 = 专属工具集 + transfer_to_agent。"""
role = ROLES[self.current_role]
schemas = [TOOL_SCHEMAS[name] for name in role.tools]
schemas.append(transfer_tool_schema()) # 每个角色都能移交
return schemas
def _messages_for_api(self) -> List[dict]:
"""把当前角色的系统提示词拼到共享历史前面。"""
system_msg = {"role": "system", "content": ROLES[self.current_role].system_prompt}
return [system_msg] + self.history
# -------------------------------------------------------------- 日志
def _log(self, msg: str):
if self.verbose:
print(msg)
def _log_role_banner(self):
role = ROLES[self.current_role]
self._log(
f"\n{C.BOLD}{C.CYAN}┌── 当前角色: {role.title} ({role.name}){C.RESET}"
f"{C.DIM} 工具: {role.tools + ['transfer_to_agent']}{C.RESET}"
)
# -------------------------------------------------------------- 单步
def _run_one_llm_turn(self) -> Optional[str]:
"""
执行一次「模型调用 + 工具处理」。
返回值:
- None 表示还要继续循环(发生了工具调用/移交)
- str 表示这是最终回复(模型没有再调用工具),流程结束
"""
self._log_role_banner()
kwargs = dict(
model=self.model,
messages=self._messages_for_api(),
tools=self._tools_for_current_role(),
temperature=0,
)
if self.max_output_tokens is not None:
kwargs["max_tokens"] = self.max_output_tokens
started = time.monotonic()
try:
response = self.client.chat.completions.create(**kwargs)
except Exception as e:
# 推理模型(如 gpt-5.x)只接受默认 temperature,会拒绝自定义值;
# 移除该参数重试一次(同 book-translation / voice-werewolf 的做法)。
if "temperature" not in str(e).lower():
raise
kwargs.pop("temperature", None)
response = self.client.chat.completions.create(**kwargs)
if self.provider_receipt_sink:
self.provider_receipt_sink({
"kind": "chat_completion",
"role": self.current_role,
"request": kwargs,
"response": response.model_dump(mode="json"),
"response_id": getattr(response, "id", None),
"response_model": getattr(response, "model", None),
"duration_seconds": round(time.monotonic() - started, 3),
})
msg = response.choices[0].message
usage = getattr(response, "usage", None)
self.api_calls.append({
"role": self.current_role,
"response_id": getattr(response, "id", None),
"history_messages_visible": len(self.history),
"tools_visible": [
tool["function"]["name"] for tool in self._tools_for_current_role()
],
"usage": usage.model_dump(mode="json") if usage is not None else None,
})
# 没有工具调用 => 最终回复
if not msg.tool_calls:
content = msg.content or ""
self.history.append({"role": "assistant", "content": content})
self.activity.append((self.current_role, "final", ""))
self._log(f"{C.GREEN}└── [{self.current_role}] 最终回复:{C.RESET}\n{content}")
return content
# 有工具调用:先把 assistant 消息(含 tool_calls)写进历史
self.history.append(
{
"role": "assistant",
"content": msg.content or "",
"tool_calls": [
{
"id": tc.id,
"type": "function",
"function": {"name": tc.function.name, "arguments": tc.function.arguments},
}
for tc in msg.tool_calls
],
}
)
pending_transfer: Optional[Handoff] = None
# 逐个处理工具调用,并为每个调用回填一条 tool 消息(OpenAI 要求)
for tc in msg.tool_calls:
name = tc.function.name
try:
args = json.loads(tc.function.arguments or "{}")
if not isinstance(args, dict):
args = {}
except (json.JSONDecodeError, TypeError):
args = {}
if name == "transfer_to_agent":
target = args.get("target_role", "")
reason = args.get("reason", "")
if isinstance(target, str) and target == self.current_role:
# 拒绝自我移交:让模型改用自己的工具或选别的角色
result = (
f"移交失败:你已经是 {target} 角色,不能移交给自己。"
"请直接使用你自己的工具完成当前部分,或移交给其他角色。"
)
self._log(f"{C.RED}└── transfer 被拒: 不能移交给自己 ({target}){C.RESET}")
elif isinstance(target, str) and target in ROLES:
pending_transfer = Handoff(self.current_role, target, reason)
self.activity.append((self.current_role, "transfer", target))
result = f"已移交给 {target}。对方将继承完整对话历史并继续处理。"
self._log(
f"{C.MAGENTA}└── ⇢ transfer_to_agent: "
f"{self.current_role}{target}{C.RESET}\n"
f" {C.YELLOW}reason:{C.RESET} {reason}"
)
else:
result = f"移交失败:未知角色 {target!r}。可选:{list(ROLES.keys())}"
self._log(f"{C.RED}└── transfer 失败: 未知角色 {target!r}{C.RESET}")
else:
impl = TOOL_IMPLEMENTATIONS.get(name)
if impl is None:
result = f"工具 {name} 不存在。"
else:
try:
if name == "web_search" and self.tool_receipt_sink:
result = impl(**args, receipt_sink=self.tool_receipt_sink)
else:
result = impl(**args)
except (TypeError, ValueError, RuntimeError) as exc:
# 模型偶尔会传错/漏参数(如 {"q": ...} 而非 {"query": ...}
# 或给出无法转换的值;把错误作为工具结果回给模型让它自行纠正,
# 而不是让整个移交流程崩溃。
result = f"工具 {name} 调用失败:{exc}。请检查参数名与取值后重试。"
self.activity.append((self.current_role, "tool", name))
# 防死循环:同一 (角色,工具,参数) 反复调用时给出纠偏提示
sig = f"{self.current_role}:{name}:{tc.function.arguments}"
self._tool_call_counts[sig] = self._tool_call_counts.get(sig, 0) + 1
if self._tool_call_counts[sig] >= 3:
result += (
"\n[系统提示] 你已多次重复完全相同的调用。请停止重复,"
"直接给出最终文本,或调用 transfer_to_agent 移交给下一个角色。"
)
self._log(
f"{C.BLUE}└── 🔧 调用工具 {name}{C.RESET} "
f"{C.DIM}args={args}{C.RESET}\n"
f" {C.DIM}{result[:300]}{C.RESET}"
)
self.history.append(
{"role": "tool", "tool_call_id": tc.id, "content": str(result)}
)
# 处理完本轮所有工具调用后,如有移交则切换角色(保留 history)
if pending_transfer is not None:
self.handoffs.append(pending_transfer)
self.current_role = pending_transfer.to_role
return None # 继续循环
# -------------------------------------------------------------- 主循环
def run(self, user_message: str) -> str:
"""处理一条用户消息,跑完整个多角色移交流程,返回最终回复。"""
self.history.append({"role": "user", "content": user_message})
self._log(f"{C.BOLD}👤 用户:{C.RESET} {user_message}")
final_answer = ""
for step in range(self.max_steps):
self.steps_used = step + 1
result = self._run_one_llm_turn()
if result is not None:
final_answer = result
break
else:
self.terminated_by_limit = True
final_answer = "(达到最大步数上限,流程终止)"
self._log(f"{C.RED}{final_answer}{C.RESET}")
return final_answer
# -------------------------------------------------------------- 汇总
def handoff_chain_str(self) -> str:
"""返回可读的移交链,如 triage → research → data_analysis → writing → triage。"""
if not self.handoffs:
return DEFAULT_ROLE + "(未发生移交)"
chain = [self.handoffs[0].from_role]
for h in self.handoffs:
chain.append(h.to_role)
return "".join(chain)
def role_work_summary(self) -> str:
"""
返回「哪个角色做了什么」的分工总览——按角色首次出场顺序,
列出每个角色实际调用过的专属工具,以及谁产出了最终回复。
这直接印证:同一段共享历史上,不同专业角色各司其职地接力完成任务。
"""
order: List[str] = []
tools_by_role: Dict[str, List[str]] = {}
final_role: Optional[str] = None
for role, kind, detail in self.activity:
if role not in order:
order.append(role)
tools_by_role[role] = []
if kind == "tool" and detail not in tools_by_role[role]:
tools_by_role[role].append(detail)
elif kind == "final":
final_role = role
if not order:
return "(无角色活动记录)"
width = max(len(r) for r in order)
lines: List[str] = []
for role in order:
used = tools_by_role[role]
desc = "".join(used) if used else "(仅路由/移交,未用专属工具)"
if role == final_role:
desc += " ⇒ 产出最终回复"
lines.append(f" {role.ljust(width)} : {desc}")
return "\n".join(lines)