|
|
@@ -26,6 +26,7 @@ from typing import AsyncIterator, Optional, Dict, Any, List, Callable, Literal,
|
|
|
from agent.trace.models import Trace, Message
|
|
|
from agent.trace.protocols import TraceStore
|
|
|
from agent.trace.goal_models import GoalTree
|
|
|
+from agent.tools.builtin.experience import _get_structured_experiences, _batch_update_experiences
|
|
|
from agent.trace.compaction import (
|
|
|
CompressionConfig,
|
|
|
filter_by_goal_status,
|
|
|
@@ -61,9 +62,11 @@ class RunConfig:
|
|
|
agent_type: str = "default"
|
|
|
uid: Optional[str] = None
|
|
|
system_prompt: Optional[str] = None # None = 从 skills 自动构建
|
|
|
+ skills: Optional[List[str]] = None # 注入 system prompt 的 skill 名称列表;None = 按 preset 决定
|
|
|
enable_memory: bool = True
|
|
|
auto_execute_tools: bool = True
|
|
|
name: Optional[str] = None # 显示名称(空则由 utility_llm 自动生成)
|
|
|
+ enable_prompt_caching: bool = True # 启用 Anthropic Prompt Caching(仅 Claude 模型有效)
|
|
|
|
|
|
# --- Trace 控制 ---
|
|
|
trace_id: Optional[str] = None # None = 新建
|
|
|
@@ -98,6 +101,7 @@ BUILTIN_TOOLS = [
|
|
|
|
|
|
# 搜索工具
|
|
|
"search_posts",
|
|
|
+ "get_experience",
|
|
|
"get_search_suggestions",
|
|
|
|
|
|
# 沙箱工具
|
|
|
@@ -181,6 +185,7 @@ class AgentRunner:
|
|
|
tool_registry: Optional[ToolRegistry] = None,
|
|
|
llm_call: Optional[Callable] = None,
|
|
|
utility_llm_call: Optional[Callable] = None,
|
|
|
+ embedding_call: Optional[Callable] = None,
|
|
|
config: Optional[AgentConfig] = None,
|
|
|
skills_dir: Optional[str] = None,
|
|
|
experiences_path: Optional[str] = "./.cache/experiences.md",
|
|
|
@@ -196,6 +201,7 @@ class AgentRunner:
|
|
|
state_store: State 存储(可选)
|
|
|
tool_registry: 工具注册表(默认使用全局注册表)
|
|
|
llm_call: 主 LLM 调用函数
|
|
|
+ embedding_call: 语义嵌入向量LLM
|
|
|
utility_llm_call: 轻量 LLM(用于生成任务标题等),可选
|
|
|
config: [向后兼容] AgentConfig
|
|
|
skills_dir: Skills 目录路径
|
|
|
@@ -208,13 +214,16 @@ class AgentRunner:
|
|
|
self.state_store = state_store
|
|
|
self.tools = tool_registry or get_tool_registry()
|
|
|
self.llm_call = llm_call
|
|
|
+ self.embedding_call = embedding_call
|
|
|
self.utility_llm_call = utility_llm_call
|
|
|
self.config = config or AgentConfig()
|
|
|
self.skills_dir = skills_dir
|
|
|
+ # 确保 experiences_path 不为 None
|
|
|
self.experiences_path = experiences_path
|
|
|
self.goal_tree = goal_tree
|
|
|
self.debug = debug
|
|
|
self._cancel_events: Dict[str, asyncio.Event] = {} # trace_id → cancel event
|
|
|
+ self.used_ex_ids: List[str] = [] # 当前运行中使用过的经验 ID
|
|
|
|
|
|
# ===== 核心公开方法 =====
|
|
|
|
|
|
@@ -289,16 +298,22 @@ class AgentRunner:
|
|
|
self,
|
|
|
messages: List[Dict],
|
|
|
config: Optional[RunConfig] = None,
|
|
|
+ on_event: Optional[Callable] = None,
|
|
|
) -> Dict[str, Any]:
|
|
|
"""
|
|
|
结果模式 — 消费 run(),返回结构化结果。
|
|
|
|
|
|
主要用于 agent/evaluate 工具内部。
|
|
|
+
|
|
|
+ Args:
|
|
|
+ on_event: 可选回调,每个 Trace/Message 事件触发一次,用于实时输出子 Agent 执行过程。
|
|
|
"""
|
|
|
last_assistant_text = ""
|
|
|
final_trace: Optional[Trace] = None
|
|
|
|
|
|
async for item in self.run(messages=messages, config=config):
|
|
|
+ if on_event:
|
|
|
+ on_event(item)
|
|
|
if isinstance(item, Message) and item.role == "assistant":
|
|
|
content = item.content
|
|
|
text = ""
|
|
|
@@ -467,6 +482,10 @@ class AgentRunner:
|
|
|
raise ValueError(f"Trace not found: {config.trace_id}")
|
|
|
|
|
|
goal_tree = await self.trace_store.get_goal_tree(config.trace_id)
|
|
|
+ if goal_tree is None:
|
|
|
+ # 防御性兜底:trace 存在但 goal.json 丢失时,创建空树
|
|
|
+ goal_tree = GoalTree(mission=trace_obj.task or "Agent task")
|
|
|
+ await self.trace_store.update_goal_tree(config.trace_id, goal_tree)
|
|
|
|
|
|
# 自动判断行为:after_sequence 为 None 或 == head → 续跑;< head → 回溯
|
|
|
after_seq = config.after_sequence
|
|
|
@@ -498,7 +517,32 @@ class AgentRunner:
|
|
|
return trace_obj, goal_tree, sequence
|
|
|
|
|
|
# ===== Phase 2: BUILD HISTORY =====
|
|
|
+ async def _get_embedding(self, text: str) -> List[float]:
|
|
|
+ """
|
|
|
+ 获取文本的嵌入向量(Embedding)
|
|
|
+
|
|
|
+ Args:
|
|
|
+ text: 需要向量化的文本
|
|
|
+
|
|
|
+ Returns:
|
|
|
+ List[float]: 嵌入向量
|
|
|
+ """
|
|
|
+ if not text or not text.strip():
|
|
|
+ return []
|
|
|
|
|
|
+ # 优先使用注入的 embedding_call
|
|
|
+ if self.embedding_call:
|
|
|
+ try:
|
|
|
+ return await self.embedding_call(text)
|
|
|
+ except Exception as e:
|
|
|
+ logger.error(f"Error in embedding_call: {e}")
|
|
|
+ raise
|
|
|
+
|
|
|
+ # 兜底方案:如果没有注入 embedding_call,但有 llm_call,
|
|
|
+ # 某些 SDK 封装可能支持通过 llm_call 的客户端直接获取
|
|
|
+ # 这里建议强制要求基础设施层提供该函数以保证分层清晰
|
|
|
+ raise ValueError("embedding_call function not provided to AgentRunner")
|
|
|
+
|
|
|
async def _build_history(
|
|
|
self,
|
|
|
trace_id: str,
|
|
|
@@ -541,36 +585,40 @@ class AgentRunner:
|
|
|
if main_path:
|
|
|
head_seq = main_path[-1].sequence
|
|
|
|
|
|
- # 2. 构建 system prompt(如果历史中没有 system message)
|
|
|
+ # 2. 构建/注入 skills 到 system prompt
|
|
|
has_system = any(m.get("role") == "system" for m in history)
|
|
|
has_system_in_new = any(m.get("role") == "system" for m in new_messages)
|
|
|
|
|
|
- if not has_system and not has_system_in_new:
|
|
|
- system_prompt = await self._build_system_prompt(config)
|
|
|
- if system_prompt:
|
|
|
- history = [{"role": "system", "content": system_prompt}] + history
|
|
|
-
|
|
|
- if self.trace_store:
|
|
|
- system_msg = Message.create(
|
|
|
- trace_id=trace_id, role="system", sequence=sequence,
|
|
|
- goal_id=None, content=system_prompt,
|
|
|
- parent_sequence=None, # system message 是 root
|
|
|
- )
|
|
|
- await self.trace_store.add_message(system_msg)
|
|
|
- created_messages.append(system_msg)
|
|
|
- head_seq = sequence
|
|
|
- sequence += 1
|
|
|
-
|
|
|
- # 3. 新建时:在第一条 user message 末尾注入当前经验
|
|
|
- if not config.trace_id: # 新建模式
|
|
|
- experiences_text = self._load_experiences()
|
|
|
- if experiences_text:
|
|
|
+ if not has_system:
|
|
|
+ if has_system_in_new:
|
|
|
+ # 入参消息已含 system,将 skills 注入其中(在 step 4 持久化之前)
|
|
|
+ augmented = []
|
|
|
for msg in new_messages:
|
|
|
- if msg.get("role") == "user" and isinstance(msg.get("content"), str):
|
|
|
- msg["content"] += f"\n\n## 参考经验\n\n{experiences_text}"
|
|
|
- break
|
|
|
+ if msg.get("role") == "system":
|
|
|
+ base = msg.get("content") or ""
|
|
|
+ enriched = await self._build_system_prompt(config, base_prompt=base)
|
|
|
+ augmented.append({**msg, "content": enriched or base})
|
|
|
+ else:
|
|
|
+ augmented.append(msg)
|
|
|
+ new_messages = augmented
|
|
|
+ else:
|
|
|
+ # 没有 system,自动构建并插入历史
|
|
|
+ system_prompt = await self._build_system_prompt(config)
|
|
|
+ if system_prompt:
|
|
|
+ history = [{"role": "system", "content": system_prompt}] + history
|
|
|
|
|
|
- # 4. 追加新 messages(设置 parent_sequence 链接到当前 head)
|
|
|
+ if self.trace_store:
|
|
|
+ system_msg = Message.create(
|
|
|
+ trace_id=trace_id, role="system", sequence=sequence,
|
|
|
+ goal_id=None, content=system_prompt,
|
|
|
+ parent_sequence=None, # system message 是 root
|
|
|
+ )
|
|
|
+ await self.trace_store.add_message(system_msg)
|
|
|
+ created_messages.append(system_msg)
|
|
|
+ head_seq = sequence
|
|
|
+ sequence += 1
|
|
|
+
|
|
|
+ # 3. 追加新 messages(设置 parent_sequence 链接到当前 head)
|
|
|
for msg_dict in new_messages:
|
|
|
history.append(msg_dict)
|
|
|
|
|
|
@@ -607,10 +655,9 @@ class AgentRunner:
|
|
|
# 当前主路径头节点的 sequence(用于设置 parent_sequence)
|
|
|
head_seq = trace.head_sequence
|
|
|
|
|
|
- # 设置 goal_tree 到 goal 工具
|
|
|
- if goal_tree and self.trace_store:
|
|
|
- from agent.trace.goal_tool import set_goal_tree
|
|
|
- set_goal_tree(goal_tree)
|
|
|
+ # 经验检索缓存:只在 goal 切换时重新检索
|
|
|
+ _last_goal_id = None
|
|
|
+ _cached_exp_text = ""
|
|
|
|
|
|
for iteration in range(config.max_iterations):
|
|
|
# 检查取消信号
|
|
|
@@ -634,6 +681,22 @@ class AgentRunner:
|
|
|
token_count = estimate_tokens(history)
|
|
|
max_tokens = compression_config.get_max_tokens(config.model)
|
|
|
|
|
|
+ # 压缩评估日志
|
|
|
+ progress_pct = (token_count / max_tokens * 100) if max_tokens > 0 else 0
|
|
|
+ msg_count = len(history)
|
|
|
+ img_count = sum(
|
|
|
+ 1 for msg in history
|
|
|
+ if isinstance(msg.get("content"), list)
|
|
|
+ for part in msg["content"]
|
|
|
+ if isinstance(part, dict) and part.get("type") in ("image", "image_url")
|
|
|
+ )
|
|
|
+ print(f"\n[压缩评估] 消息数: {msg_count} | 图片数: {img_count} | Token: {token_count:,} / {max_tokens:,} ({progress_pct:.1f}%)")
|
|
|
+
|
|
|
+ if token_count > max_tokens:
|
|
|
+ print(f"[压缩评估] ⚠️ 超过阈值,触发压缩流程")
|
|
|
+ else:
|
|
|
+ print(f"[压缩评估] ✅ 未超阈值,无需压缩")
|
|
|
+
|
|
|
if token_count > max_tokens and self.trace_store and goal_tree:
|
|
|
# 使用本地 head_seq(store 中的 head_sequence 在 loop 期间未更新,是过时的)
|
|
|
if head_seq > 0:
|
|
|
@@ -642,12 +705,21 @@ class AgentRunner:
|
|
|
)
|
|
|
filtered_msgs = filter_by_goal_status(main_path_msgs, goal_tree)
|
|
|
if len(filtered_msgs) < len(main_path_msgs):
|
|
|
+ filtered_tokens = estimate_tokens([msg.to_llm_dict() for msg in filtered_msgs])
|
|
|
+ print(
|
|
|
+ f"[Level 1 压缩] 消息: {len(main_path_msgs)} → {len(filtered_msgs)} 条 | "
|
|
|
+ f"Token: {token_count:,} → ~{filtered_tokens:,}"
|
|
|
+ )
|
|
|
logger.info(
|
|
|
"Level 1 压缩: %d -> %d 条消息 (tokens ~%d, 阈值 %d)",
|
|
|
len(main_path_msgs), len(filtered_msgs), token_count, max_tokens,
|
|
|
)
|
|
|
history = [msg.to_llm_dict() for msg in filtered_msgs]
|
|
|
else:
|
|
|
+ print(
|
|
|
+ f"[Level 1 压缩] 无可过滤消息 ({len(main_path_msgs)} 条全部保留, "
|
|
|
+ f"completed/abandoned goals={sum(1 for g in goal_tree.goals if g.status in ('completed', 'abandoned'))})"
|
|
|
+ )
|
|
|
logger.info(
|
|
|
"Level 1 压缩: 无可过滤消息 (%d 条全部保留, completed/abandoned goals=%d)",
|
|
|
len(main_path_msgs),
|
|
|
@@ -655,6 +727,7 @@ class AgentRunner:
|
|
|
if g.status in ("completed", "abandoned")),
|
|
|
)
|
|
|
elif token_count > max_tokens:
|
|
|
+ print("[压缩评估] ⚠️ 无法执行 Level 1 压缩(缺少 store 或 goal_tree)")
|
|
|
logger.warning(
|
|
|
"消息 token 数 (%d) 超过阈值 (%d),但无法执行 Level 1 压缩(缺少 store 或 goal_tree)",
|
|
|
token_count, max_tokens,
|
|
|
@@ -663,6 +736,11 @@ class AgentRunner:
|
|
|
# Level 2 压缩:LLM 总结(Level 1 后仍超阈值时触发)
|
|
|
token_count_after = estimate_tokens(history)
|
|
|
if token_count_after > max_tokens:
|
|
|
+ progress_pct_after = (token_count_after / max_tokens * 100) if max_tokens > 0 else 0
|
|
|
+ print(
|
|
|
+ f"[Level 2 压缩] Level 1 后仍超阈值: {token_count_after:,} / {max_tokens:,} ({progress_pct_after:.1f}%) "
|
|
|
+ f"→ 触发 LLM 总结"
|
|
|
+ )
|
|
|
logger.info(
|
|
|
"Level 1 后 token 仍超阈值 (%d > %d),触发 Level 2 压缩",
|
|
|
token_count_after, max_tokens,
|
|
|
@@ -670,16 +748,63 @@ class AgentRunner:
|
|
|
history, head_seq, sequence = await self._compress_history(
|
|
|
trace_id, history, goal_tree, config, sequence, head_seq,
|
|
|
)
|
|
|
+ final_tokens = estimate_tokens(history)
|
|
|
+ print(f"[Level 2 压缩] 完成: Token {token_count_after:,} → {final_tokens:,}")
|
|
|
+ elif token_count > max_tokens:
|
|
|
+ # Level 1 压缩成功,未触发 Level 2
|
|
|
+ print(f"[压缩评估] ✅ Level 1 压缩后达标: {token_count_after:,} / {max_tokens:,}")
|
|
|
+ print() # 空行分隔
|
|
|
|
|
|
# 构建 LLM messages(注入上下文)
|
|
|
llm_messages = list(history)
|
|
|
|
|
|
+ # 先对历史消息应用 Prompt Caching(在注入动态内容之前)
|
|
|
+ # 这样可以确保历史消息的缓存点固定,不受动态注入影响
|
|
|
+ llm_messages = self._add_cache_control(
|
|
|
+ llm_messages,
|
|
|
+ config.model,
|
|
|
+ config.enable_prompt_caching
|
|
|
+ )
|
|
|
+
|
|
|
+ # 然后追加动态注入的内容(不影响已缓存的历史消息)
|
|
|
# 周期性注入 GoalTree + Collaborators
|
|
|
if iteration % CONTEXT_INJECTION_INTERVAL == 0:
|
|
|
context_injection = self._build_context_injection(trace, goal_tree)
|
|
|
if context_injection:
|
|
|
llm_messages.append({"role": "system", "content": context_injection})
|
|
|
|
|
|
+ # 经验检索:goal 切换时重新检索,注入为 system message
|
|
|
+ current_goal_id = goal_tree.current_id if goal_tree else None
|
|
|
+ if current_goal_id and current_goal_id != _last_goal_id:
|
|
|
+ _last_goal_id = current_goal_id
|
|
|
+ current_goal = goal_tree.find(current_goal_id)
|
|
|
+ if current_goal:
|
|
|
+ try:
|
|
|
+ relevant_exps = await _get_structured_experiences(
|
|
|
+ query_text=current_goal.description,
|
|
|
+ top_k=3,
|
|
|
+ context={"runner": self}
|
|
|
+ )
|
|
|
+ if relevant_exps:
|
|
|
+ self.used_ex_ids = [exp['id'] for exp in relevant_exps]
|
|
|
+ parts = [f"[{exp['id']}] {exp['content']}" for exp in relevant_exps]
|
|
|
+ _cached_exp_text = "## 参考历史经验\n" + "\n\n".join(parts)
|
|
|
+ logger.info(
|
|
|
+ "经验检索: goal='%s', 命中 %d 条 %s",
|
|
|
+ current_goal.description[:40],
|
|
|
+ len(relevant_exps),
|
|
|
+ self.used_ex_ids,
|
|
|
+ )
|
|
|
+ else:
|
|
|
+ _cached_exp_text = ""
|
|
|
+ except Exception as e:
|
|
|
+ logger.warning("经验检索失败: %s", e)
|
|
|
+ _cached_exp_text = ""
|
|
|
+
|
|
|
+ # 经验注入:goal切换时注入相关历史经验
|
|
|
+ if _cached_exp_text:
|
|
|
+ llm_messages.append({"role": "system", "content": _cached_exp_text})
|
|
|
+
|
|
|
# 调用 LLM
|
|
|
result = await self.llm_call(
|
|
|
messages=llm_messages,
|
|
|
@@ -695,6 +820,8 @@ class AgentRunner:
|
|
|
prompt_tokens = result.get("prompt_tokens", 0)
|
|
|
completion_tokens = result.get("completion_tokens", 0)
|
|
|
step_cost = result.get("cost", 0)
|
|
|
+ cache_creation_tokens = result.get("cache_creation_tokens")
|
|
|
+ cache_read_tokens = result.get("cache_read_tokens")
|
|
|
|
|
|
# 按需自动创建 root goal
|
|
|
if goal_tree and not goal_tree.goals and tool_calls:
|
|
|
@@ -712,8 +839,8 @@ class AgentRunner:
|
|
|
)
|
|
|
goal_tree.focus(goal_tree.goals[0].id)
|
|
|
if self.trace_store:
|
|
|
- await self.trace_store.update_goal_tree(trace_id, goal_tree)
|
|
|
await self.trace_store.add_goal(trace_id, goal_tree.goals[0])
|
|
|
+ await self.trace_store.update_goal_tree(trace_id, goal_tree)
|
|
|
logger.info(f"自动创建 root goal: {goal_tree.goals[0].id}")
|
|
|
|
|
|
# 获取当前 goal_id
|
|
|
@@ -729,6 +856,8 @@ class AgentRunner:
|
|
|
content={"text": response_content, "tool_calls": tool_calls},
|
|
|
prompt_tokens=prompt_tokens,
|
|
|
completion_tokens=completion_tokens,
|
|
|
+ cache_creation_tokens=cache_creation_tokens,
|
|
|
+ cache_read_tokens=cache_read_tokens,
|
|
|
finish_reason=finish_reason,
|
|
|
cost=step_cost,
|
|
|
)
|
|
|
@@ -793,6 +922,7 @@ class AgentRunner:
|
|
|
"trace_id": trace_id,
|
|
|
"goal_id": current_goal_id,
|
|
|
"runner": self,
|
|
|
+ "goal_tree": goal_tree,
|
|
|
}
|
|
|
)
|
|
|
|
|
|
@@ -824,7 +954,8 @@ class AgentRunner:
|
|
|
goal_id=current_goal_id,
|
|
|
parent_sequence=head_seq,
|
|
|
tool_call_id=tc["id"],
|
|
|
- content={"tool_name": tool_name, "result": tool_result_text},
|
|
|
+ # 存储完整内容:有图片时保留 list(含 image_url),纯文本时存字符串
|
|
|
+ content={"tool_name": tool_name, "result": tool_content_for_llm},
|
|
|
)
|
|
|
|
|
|
if self.trace_store:
|
|
|
@@ -920,34 +1051,84 @@ class AgentRunner:
|
|
|
|
|
|
# --- Step 1: 经验提取(reflect)---
|
|
|
try:
|
|
|
+ # 1. 构造 Reflect Prompt(确保包含格式要求)
|
|
|
+ # 建议在 build_reflect_prompt() 里加入:
|
|
|
+ # "请使用格式:- [intent: 意图, state: 状态描述] 具体的经验内容"
|
|
|
reflect_prompt = build_reflect_prompt()
|
|
|
reflect_messages = list(history) + [{"role": "user", "content": reflect_prompt}]
|
|
|
|
|
|
+ # 应用 Prompt Caching
|
|
|
+ reflect_messages = self._add_cache_control(
|
|
|
+ reflect_messages,
|
|
|
+ config.model,
|
|
|
+ config.enable_prompt_caching
|
|
|
+ )
|
|
|
+
|
|
|
reflect_result = await self.llm_call(
|
|
|
messages=reflect_messages,
|
|
|
model=config.model,
|
|
|
tools=[],
|
|
|
- temperature=config.temperature,
|
|
|
+ temperature=0.2, # 略微保持一点发散性
|
|
|
**config.extra_llm_params,
|
|
|
)
|
|
|
|
|
|
- reflect_content = reflect_result.get("content", "").strip()
|
|
|
- if reflect_content and self.experiences_path:
|
|
|
- try:
|
|
|
+ reflection_text = reflect_result.get("content", "").strip()
|
|
|
+
|
|
|
+ if reflection_text:
|
|
|
+ import re as _re2
|
|
|
+ import uuid as _uuid2
|
|
|
+
|
|
|
+ pattern = r"-\s*\[(?P<tags>.*?)\]\s*(?P<content>.*)"
|
|
|
+ matches = list(_re2.finditer(pattern, reflection_text))
|
|
|
+
|
|
|
+ structured_entries = []
|
|
|
+ for match in matches:
|
|
|
+ tags_str = match.group("tags")
|
|
|
+ content = match.group("content")
|
|
|
+
|
|
|
+ intent_match = _re2.search(r"intent:\s*(.*?)(?:,|$)", tags_str, _re2.IGNORECASE)
|
|
|
+ state_match = _re2.search(r"state:\s*(.*?)(?:,|$)", tags_str, _re2.IGNORECASE)
|
|
|
+
|
|
|
+ intents = [i.strip() for i in intent_match.group(1).split(",")] if intent_match and intent_match.group(1) else []
|
|
|
+ states = [s.strip() for s in state_match.group(1).split(",")] if state_match and state_match.group(1) else []
|
|
|
+
|
|
|
+ ex_id = f"ex_{datetime.now().strftime('%m%d%H%M')}_{_uuid2.uuid4().hex[:4]}"
|
|
|
+ entry = f"""---
|
|
|
+id: {ex_id}
|
|
|
+trace_id: {trace_id}
|
|
|
+tags: {{intent: {intents}, state: {states}}}
|
|
|
+metrics: {{helpful: 1, harmful: 0}}
|
|
|
+created_at: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
|
|
|
+---
|
|
|
+- {content}
|
|
|
+- 经验ID: [{ex_id}]"""
|
|
|
+ structured_entries.append(entry)
|
|
|
+
|
|
|
+ if structured_entries:
|
|
|
os.makedirs(os.path.dirname(self.experiences_path), exist_ok=True)
|
|
|
with open(self.experiences_path, "a", encoding="utf-8") as f:
|
|
|
- f.write(f"\n\n---\n\n{reflect_content}")
|
|
|
- logger.info("经验已追加到 %s", self.experiences_path)
|
|
|
- except Exception as e:
|
|
|
- logger.warning("写入经验文件失败: %s", e)
|
|
|
+ f.write("\n\n" + "\n\n".join(structured_entries))
|
|
|
+ logger.info(f"已提取并保存 {len(structured_entries)} 条结构化经验")
|
|
|
+ else:
|
|
|
+ logger.warning("未能解析出符合格式的经验条目,请检查 REFLECT_PROMPT。")
|
|
|
+ logger.debug(f"LLM Raw Output:\n{reflection_text}")
|
|
|
+ else:
|
|
|
+ logger.warning("LLM 未生成反思内容")
|
|
|
|
|
|
except Exception as e:
|
|
|
- logger.warning("Level 2 经验提取失败(不影响压缩): %s", e)
|
|
|
+ logger.error(f"Level 2 经验提取失败: {e}")
|
|
|
|
|
|
- # --- Step 2: 压缩总结 ---
|
|
|
- compress_prompt = build_compression_prompt(goal_tree)
|
|
|
+ # --- Step 2: 压缩总结 + 经验评估 ---
|
|
|
+ compress_prompt = build_compression_prompt(goal_tree, used_ex_ids=self.used_ex_ids)
|
|
|
compress_messages = list(history) + [{"role": "user", "content": compress_prompt}]
|
|
|
|
|
|
+ # 应用 Prompt Caching
|
|
|
+ compress_messages = self._add_cache_control(
|
|
|
+ compress_messages,
|
|
|
+ config.model,
|
|
|
+ config.enable_prompt_caching
|
|
|
+ )
|
|
|
+
|
|
|
compress_result = await self.llm_call(
|
|
|
messages=compress_messages,
|
|
|
model=config.model,
|
|
|
@@ -956,7 +1137,44 @@ class AgentRunner:
|
|
|
**config.extra_llm_params,
|
|
|
)
|
|
|
|
|
|
- summary_text = compress_result.get("content", "").strip()
|
|
|
+ raw_output = compress_result.get("content", "").strip()
|
|
|
+ if not raw_output:
|
|
|
+ logger.warning("Level 2 压缩跳过:LLM 未返回内容")
|
|
|
+ return history, head_seq, sequence
|
|
|
+
|
|
|
+ # 解析 [[EVALUATION]] 块并更新经验
|
|
|
+ if self.used_ex_ids:
|
|
|
+ try:
|
|
|
+ eval_block = ""
|
|
|
+ if "[[EVALUATION]]" in raw_output:
|
|
|
+ eval_start = raw_output.index("[[EVALUATION]]") + len("[[EVALUATION]]")
|
|
|
+ eval_end = raw_output.index("[[SUMMARY]]") if "[[SUMMARY]]" in raw_output else len(raw_output)
|
|
|
+ eval_block = raw_output[eval_start:eval_end].strip()
|
|
|
+
|
|
|
+ if eval_block:
|
|
|
+ import re as _re
|
|
|
+ update_map = {}
|
|
|
+ for line in eval_block.splitlines():
|
|
|
+ m = _re.search(r"ID:\s*(ex_\S+)\s*\|\s*Result:\s*(\w+)", line)
|
|
|
+ if m:
|
|
|
+ ex_id, result = m.group(1), m.group(2).lower()
|
|
|
+ if result in ("helpful", "harmful"):
|
|
|
+ update_map[ex_id] = {"action": result, "feedback": ""}
|
|
|
+ elif result == "mixed":
|
|
|
+ update_map[ex_id] = {"action": "helpful", "feedback": ""}
|
|
|
+ if update_map:
|
|
|
+ count = await _batch_update_experiences(update_map, context={"runner": self})
|
|
|
+ logger.info("经验评估完成,更新了 %s 条经验", count)
|
|
|
+ except Exception as e:
|
|
|
+ logger.warning("经验评估解析失败(不影响压缩): %s", e)
|
|
|
+
|
|
|
+ # 提取 [[SUMMARY]] 块
|
|
|
+ summary_text = raw_output
|
|
|
+ if "[[SUMMARY]]" in raw_output:
|
|
|
+ summary_text = raw_output[raw_output.index("[[SUMMARY]]") + len("[[SUMMARY]]"):].strip()
|
|
|
+
|
|
|
+ # 压缩完成后清空 used_ex_ids
|
|
|
+ self.used_ex_ids = []
|
|
|
if not summary_text:
|
|
|
logger.warning("Level 2 压缩跳过:LLM 未返回 summary")
|
|
|
return history, head_seq, sequence
|
|
|
@@ -1288,6 +1506,122 @@ class AgentRunner:
|
|
|
|
|
|
# ===== 辅助方法 =====
|
|
|
|
|
|
+ def _add_cache_control(
|
|
|
+ self,
|
|
|
+ messages: List[Dict],
|
|
|
+ model: str,
|
|
|
+ enable: bool
|
|
|
+ ) -> List[Dict]:
|
|
|
+ """
|
|
|
+ 为支持的模型添加 Prompt Caching 标记
|
|
|
+
|
|
|
+ 策略:固定位置缓存点,提高缓存命中率
|
|
|
+ 1. system message 添加缓存(如果存在且足够长)
|
|
|
+ 2. 每 20 条 user/assistant/tool 消息添加一个固定缓存点(位置:20, 40, 60)
|
|
|
+ 3. 最多使用 4 个缓存点(含 system)
|
|
|
+
|
|
|
+ Args:
|
|
|
+ messages: 原始消息列表
|
|
|
+ model: 模型名称
|
|
|
+ enable: 是否启用缓存
|
|
|
+
|
|
|
+ Returns:
|
|
|
+ 添加了 cache_control 的消息列表(深拷贝)
|
|
|
+ """
|
|
|
+ if not enable:
|
|
|
+ return messages
|
|
|
+
|
|
|
+ # 只对 Claude 模型启用
|
|
|
+ if "claude" not in model.lower():
|
|
|
+ return messages
|
|
|
+
|
|
|
+ # 深拷贝避免修改原始数据
|
|
|
+ import copy
|
|
|
+ messages = copy.deepcopy(messages)
|
|
|
+
|
|
|
+ # 策略 1: 为 system message 添加缓存
|
|
|
+ system_cached = False
|
|
|
+ for msg in messages:
|
|
|
+ if msg.get("role") == "system":
|
|
|
+ content = msg.get("content", "")
|
|
|
+ # 只有足够长的 system prompt 才值得缓存(>1024 tokens 约 4000 字符)
|
|
|
+ if isinstance(content, str) and len(content) > 1000:
|
|
|
+ msg["content"] = [
|
|
|
+ {
|
|
|
+ "type": "text",
|
|
|
+ "text": content,
|
|
|
+ "cache_control": {"type": "ephemeral"}
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ system_cached = True
|
|
|
+ logger.debug(f"[Cache] 为 system message 添加缓存标记 (len={len(content)})")
|
|
|
+ break
|
|
|
+
|
|
|
+ # 策略 2: 按总消息数计算缓存点(包括 tool 消息)
|
|
|
+ # 但只能在 user/assistant 消息上添加 cache_control
|
|
|
+ total_msgs = len(messages)
|
|
|
+ if total_msgs == 0:
|
|
|
+ return messages
|
|
|
+
|
|
|
+ # 每 20 条总消息添加一个缓存点
|
|
|
+ # 原因:Anthropic 要求每个缓存点至少 1024 tokens
|
|
|
+ # 每 15 条消息约 1050 tokens,太接近边界,改为 20 条确保足够(约 1400 tokens)
|
|
|
+ CACHE_INTERVAL = 20
|
|
|
+ max_cache_points = 3 if system_cached else 4
|
|
|
+
|
|
|
+ cache_positions = []
|
|
|
+ for i in range(1, max_cache_points + 1):
|
|
|
+ target_pos = i * CACHE_INTERVAL - 1 # 第 20, 40, 60, 80 条
|
|
|
+ if target_pos < total_msgs:
|
|
|
+ # 从 target_pos 往前找最近的 user/assistant 消息
|
|
|
+ for j in range(target_pos, -1, -1):
|
|
|
+ if messages[j].get("role") in ("user", "assistant"):
|
|
|
+ cache_positions.append(j)
|
|
|
+ break
|
|
|
+
|
|
|
+ # 应用缓存标记
|
|
|
+ for idx in cache_positions:
|
|
|
+ msg = messages[idx]
|
|
|
+ content = msg.get("content", "")
|
|
|
+ role = msg.get("role", "")
|
|
|
+
|
|
|
+ print(f"[Cache] 尝试为 message[{idx}] (role={role}, content_type={type(content).__name__}) 添加缓存标记")
|
|
|
+
|
|
|
+ # 处理 string content
|
|
|
+ if isinstance(content, str):
|
|
|
+ msg["content"] = [
|
|
|
+ {
|
|
|
+ "type": "text",
|
|
|
+ "text": content,
|
|
|
+ "cache_control": {"type": "ephemeral"}
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ print(f"[Cache] ✓ 为 message[{idx}] ({role}) 添加缓存标记 (str->list)")
|
|
|
+ logger.debug(f"[Cache] 为 message[{idx}] ({msg.get('role')}) 添加缓存标记")
|
|
|
+
|
|
|
+ # 处理 list content(多模态消息)
|
|
|
+ elif isinstance(content, list) and len(content) > 0:
|
|
|
+ # 在最后一个 text block 添加 cache_control
|
|
|
+ for i in range(len(content) - 1, -1, -1):
|
|
|
+ if isinstance(content[i], dict) and content[i].get("type") == "text":
|
|
|
+ content[i]["cache_control"] = {"type": "ephemeral"}
|
|
|
+ print(f"[Cache] ✓ 为 message[{idx}] ({role}) 的 content[{i}] 添加缓存标记 (list)")
|
|
|
+ logger.debug(f"[Cache] 为 message[{idx}] ({msg.get('role')}) 的 content[{i}] 添加缓存标记")
|
|
|
+ break
|
|
|
+ else:
|
|
|
+ print(f"[Cache] ✗ message[{idx}] ({role}) 的 content 类型不支持: {type(content).__name__}, len={len(content) if isinstance(content, (list, str)) else 'N/A'}")
|
|
|
+
|
|
|
+ total_cache_points = len(cache_positions) + (1 if system_cached else 0)
|
|
|
+ print(
|
|
|
+ f"[Cache] 总消息: {len(messages)}, "
|
|
|
+ f"缓存点: {total_cache_points} at positions: {cache_positions}"
|
|
|
+ )
|
|
|
+ logger.debug(
|
|
|
+ f"[Cache] 总消息: {len(messages)}, "
|
|
|
+ f"缓存点: {total_cache_points} at positions: {cache_positions}"
|
|
|
+ )
|
|
|
+ return messages
|
|
|
+
|
|
|
def _get_tool_schemas(self, tools: Optional[List[str]]) -> List[Dict]:
|
|
|
"""
|
|
|
获取工具 Schema
|
|
|
@@ -1309,17 +1643,38 @@ class AgentRunner:
|
|
|
# 默认 system prompt 前缀(当 config.system_prompt 和前端都未提供 system message 时使用)
|
|
|
DEFAULT_SYSTEM_PREFIX = "你是最顶尖的AI助手,可以拆分并调用工具逐步解决复杂问题。"
|
|
|
|
|
|
- async def _build_system_prompt(self, config: RunConfig) -> Optional[str]:
|
|
|
- """构建 system prompt(注入 skills)"""
|
|
|
- system_prompt = config.system_prompt
|
|
|
+ async def _build_system_prompt(self, config: RunConfig, base_prompt: Optional[str] = None) -> Optional[str]:
|
|
|
+ """构建 system prompt(注入 skills)
|
|
|
+
|
|
|
+ 优先级:
|
|
|
+ 1. config.skills 显式指定 → 按名称过滤
|
|
|
+ 2. config.skills 为 None → 查 preset 的默认 skills 列表
|
|
|
+ 3. preset 也无 skills(None)→ 加载全部(向后兼容)
|
|
|
+
|
|
|
+ Args:
|
|
|
+ base_prompt: 已有 system 内容(来自消息或 config.system_prompt),
|
|
|
+ None 时使用 config.system_prompt
|
|
|
+ """
|
|
|
+ from agent.core.presets import AGENT_PRESETS
|
|
|
+
|
|
|
+ system_prompt = base_prompt if base_prompt is not None else config.system_prompt
|
|
|
+
|
|
|
+ # 确定要加载哪些 skills
|
|
|
+ skills_filter: Optional[List[str]] = config.skills
|
|
|
+ if skills_filter is None:
|
|
|
+ preset = AGENT_PRESETS.get(config.agent_type)
|
|
|
+ if preset is not None:
|
|
|
+ skills_filter = preset.skills # 可能仍为 None(加载全部)
|
|
|
+
|
|
|
+ # 加载并过滤
|
|
|
+ all_skills = load_skills_from_dir(self.skills_dir)
|
|
|
+ if skills_filter is not None:
|
|
|
+ skills = [s for s in all_skills if s.name in skills_filter]
|
|
|
+ else:
|
|
|
+ skills = all_skills
|
|
|
|
|
|
- # 加载 Skills
|
|
|
- skills_text = ""
|
|
|
- skills = load_skills_from_dir(self.skills_dir)
|
|
|
- if skills:
|
|
|
- skills_text = self._format_skills(skills)
|
|
|
+ skills_text = self._format_skills(skills) if skills else ""
|
|
|
|
|
|
- # 拼装:有自定义 system_prompt 则用它,否则用默认前缀
|
|
|
if system_prompt:
|
|
|
if skills_text:
|
|
|
system_prompt += f"\n\n## Skills\n{skills_text}"
|