|
@@ -1,17 +1,8 @@
|
|
|
-"""
|
|
|
|
|
-Agent Runner - Agent 执行引擎
|
|
|
|
|
-
|
|
|
|
|
-核心职责:
|
|
|
|
|
-1. 执行 Agent 任务(循环调用 LLM + 工具)
|
|
|
|
|
-2. 记录执行轨迹(Trace + Messages + GoalTree)
|
|
|
|
|
-3. 加载和注入技能(Skill)
|
|
|
|
|
-4. 管理执行计划(GoalTree)
|
|
|
|
|
-5. 支持续跑(continue)和回溯重跑(rewind)
|
|
|
|
|
-
|
|
|
|
|
-参数分层:
|
|
|
|
|
-- Infrastructure: AgentRunner 构造时设置(trace_store, llm_call 等)
|
|
|
|
|
-- RunConfig: 每次 run 时指定(model, trace_id, after_sequence 等)
|
|
|
|
|
-- Messages: OpenAI SDK 格式的任务消息
|
|
|
|
|
|
|
+"""Public execution facade for Agent loops and resumable Trace state.
|
|
|
|
|
+
|
|
|
|
|
+``AgentRunner`` retains the stable calling surface while delegating prompt and
|
|
|
|
|
+tool-policy concerns to focused internal components. It still owns loop state,
|
|
|
|
|
+Trace continuity, context management, and rewind semantics.
|
|
|
"""
|
|
"""
|
|
|
|
|
|
|
|
import asyncio
|
|
import asyncio
|
|
@@ -48,28 +39,22 @@ from agent.trace.compaction import (
|
|
|
estimate_tokens,
|
|
estimate_tokens,
|
|
|
)
|
|
)
|
|
|
from agent.skill.models import Skill
|
|
from agent.skill.models import Skill
|
|
|
-from agent.skill.skill_loader import load_skills_from_dir
|
|
|
|
|
from agent.tools import ToolRegistry, get_tool_registry
|
|
from agent.tools import ToolRegistry, get_tool_registry
|
|
|
-from agent.tools.builtin.knowledge import KnowledgeConfig
|
|
|
|
|
|
|
+from agent.core.knowledge_config import KnowledgeConfig
|
|
|
from agent.core.memory import MemoryConfig
|
|
from agent.core.memory import MemoryConfig
|
|
|
-from agent.core.presets import get_preset
|
|
|
|
|
from agent.orchestration.models import AgentRole, CompletionPolicy
|
|
from agent.orchestration.models import AgentRole, CompletionPolicy
|
|
|
from agent.orchestration.policy import (
|
|
from agent.orchestration.policy import (
|
|
|
DefaultToolPolicy,
|
|
DefaultToolPolicy,
|
|
|
ResolvedAgentPolicy,
|
|
ResolvedAgentPolicy,
|
|
|
- PLANNER_TOOLS,
|
|
|
|
|
- WORKER_TOOLS,
|
|
|
|
|
- VALIDATOR_TOOLS,
|
|
|
|
|
)
|
|
)
|
|
|
|
|
+from agent.core.runner_tools import RunnerToolRuntime
|
|
|
|
|
+from agent.core.runner_prompts import RunnerPromptRuntime
|
|
|
|
|
+from agent.core.runner_images import RunnerImageRuntime
|
|
|
from agent.core.prompts import (
|
|
from agent.core.prompts import (
|
|
|
- DEFAULT_SYSTEM_PREFIX,
|
|
|
|
|
TRUNCATION_HINT,
|
|
TRUNCATION_HINT,
|
|
|
AGENT_INTERRUPTED_SUMMARY,
|
|
AGENT_INTERRUPTED_SUMMARY,
|
|
|
- TASK_NAME_GENERATION_SYSTEM_PROMPT,
|
|
|
|
|
- TASK_NAME_FALLBACK,
|
|
|
|
|
build_tool_interrupted_message,
|
|
build_tool_interrupted_message,
|
|
|
build_agent_continue_hint,
|
|
build_agent_continue_hint,
|
|
|
- ROLE_CONTRACTS,
|
|
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
logger = logging.getLogger(__name__)
|
|
@@ -197,8 +182,7 @@ class RunConfig:
|
|
|
# None = 默认 Agent(无长期记忆);赋值 MemoryConfig 使该 Agent 成为 memory-bearing Agent
|
|
# None = 默认 Agent(无长期记忆);赋值 MemoryConfig 使该 Agent 成为 memory-bearing Agent
|
|
|
memory: Optional["MemoryConfig"] = None
|
|
memory: Optional["MemoryConfig"] = None
|
|
|
|
|
|
|
|
- # BUILTIN_TOOLS 硬编码列表已移除(2026-04)。
|
|
|
|
|
- # 工具可用性现在由 @tool(groups=[...]) 声明 + RunConfig.tool_groups 过滤控制。
|
|
|
|
|
|
|
+ # Tool availability is resolved from registry groups and role policy.
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
@dataclass
|
|
@@ -281,12 +265,17 @@ class AgentRunner:
|
|
|
# 图片优化缓存(避免重复处理)
|
|
# 图片优化缓存(避免重复处理)
|
|
|
# key: 图片内容的 hash, value: {"downscaled": ..., "description": ...}
|
|
# key: 图片内容的 hash, value: {"downscaled": ..., "description": ...}
|
|
|
self._image_opt_cache: Dict[str, Dict[str, Any]] = {}
|
|
self._image_opt_cache: Dict[str, Dict[str, Any]] = {}
|
|
|
|
|
+ self._image_runtime = RunnerImageRuntime(self)
|
|
|
|
|
|
|
|
# 当前 run 的 MemoryConfig(由 run() 根据 RunConfig.memory 设置)
|
|
# 当前 run 的 MemoryConfig(由 run() 根据 RunConfig.memory 设置)
|
|
|
# dream 工具从 context.runner 读取此字段,判断是否 memory-bearing
|
|
# dream 工具从 context.runner 读取此字段,判断是否 memory-bearing
|
|
|
self._current_memory_config: Optional[MemoryConfig] = None
|
|
self._current_memory_config: Optional[MemoryConfig] = None
|
|
|
self.task_coordinator = task_coordinator
|
|
self.task_coordinator = task_coordinator
|
|
|
self.tool_policy = tool_policy or DefaultToolPolicy()
|
|
self.tool_policy = tool_policy or DefaultToolPolicy()
|
|
|
|
|
+ self._tool_runtime = RunnerToolRuntime(self.tools, self.tool_policy, self.log)
|
|
|
|
|
+ self._prompt_runtime = RunnerPromptRuntime(
|
|
|
|
|
+ logger=self.log,
|
|
|
|
|
+ )
|
|
|
|
|
|
|
|
# ===== 核心公开方法 =====
|
|
# ===== 核心公开方法 =====
|
|
|
|
|
|
|
@@ -713,7 +702,7 @@ class AgentRunner:
|
|
|
|
|
|
|
|
await broadcast_trace_status_changed(config.trace_id, "running")
|
|
await broadcast_trace_status_changed(config.trace_id, "running")
|
|
|
except Exception:
|
|
except Exception:
|
|
|
- pass
|
|
|
|
|
|
|
+ self.log.debug("Trace status broadcast failed", exc_info=True)
|
|
|
|
|
|
|
|
return trace_obj, goal_tree, sequence
|
|
return trace_obj, goal_tree, sequence
|
|
|
|
|
|
|
@@ -1279,7 +1268,7 @@ class AgentRunner:
|
|
|
|
|
|
|
|
await broadcast_trace_status_changed(trace_id, "stopped")
|
|
await broadcast_trace_status_changed(trace_id, "stopped")
|
|
|
except Exception:
|
|
except Exception:
|
|
|
- pass
|
|
|
|
|
|
|
+ self.log.debug("Stopped status broadcast failed", exc_info=True)
|
|
|
trace_obj = await self.trace_store.get_trace(trace_id)
|
|
trace_obj = await self.trace_store.get_trace(trace_id)
|
|
|
if trace_obj:
|
|
if trace_obj:
|
|
|
yield trace_obj
|
|
yield trace_obj
|
|
@@ -2301,8 +2290,11 @@ class AgentRunner:
|
|
|
}
|
|
}
|
|
|
)
|
|
)
|
|
|
img_count = len(tool_content_for_llm) - 1 # 减去 text 块
|
|
img_count = len(tool_content_for_llm) - 1 # 减去 text 块
|
|
|
- print(
|
|
|
|
|
- f"[Runner] 多模态工具反馈: tool={tool_name}, images={img_count}, text_len={len(tool_result_text)}"
|
|
|
|
|
|
|
+ self.log.debug(
|
|
|
|
|
+ "Multimodal tool result: tool=%s images=%d text_len=%d",
|
|
|
|
|
+ tool_name,
|
|
|
|
|
+ img_count,
|
|
|
|
|
+ len(tool_result_text),
|
|
|
)
|
|
)
|
|
|
else:
|
|
else:
|
|
|
tool_result_text = tool_text
|
|
tool_result_text = tool_text
|
|
@@ -3184,645 +3176,47 @@ class AgentRunner:
|
|
|
# ===== 辅助方法 =====
|
|
# ===== 辅助方法 =====
|
|
|
|
|
|
|
|
async def _optimize_images(self, messages: List[Dict], model: str) -> List[Dict]:
|
|
async def _optimize_images(self, messages: List[Dict], model: str) -> List[Dict]:
|
|
|
- """
|
|
|
|
|
- 分级优化已处理的图片,节省 token
|
|
|
|
|
-
|
|
|
|
|
- 策略(基于图片距离最后一条 assistant 的"轮次"):
|
|
|
|
|
- 1. 最近 1-2 轮:保留原图
|
|
|
|
|
- 2. 3-5 轮:降低分辨率和压缩(节省 token 但保留视觉信息)
|
|
|
|
|
- 3. 5 轮以上:调用小模型生成文本描述 + 保留 URL
|
|
|
|
|
-
|
|
|
|
|
- 处理结果会缓存,避免重复的 PIL 解码/编码和 LLM 调用。
|
|
|
|
|
-
|
|
|
|
|
- Args:
|
|
|
|
|
- messages: 原始消息列表
|
|
|
|
|
- model: 当前使用的模型(用于选择描述生成模型)
|
|
|
|
|
-
|
|
|
|
|
- Returns:
|
|
|
|
|
- 优化后的消息列表(深拷贝)
|
|
|
|
|
- """
|
|
|
|
|
- if not messages:
|
|
|
|
|
- return messages
|
|
|
|
|
-
|
|
|
|
|
- # 找到最后一条 assistant message 的位置
|
|
|
|
|
- last_assistant_idx = -1
|
|
|
|
|
- for i in range(len(messages) - 1, -1, -1):
|
|
|
|
|
- if messages[i].get("role") == "assistant":
|
|
|
|
|
- last_assistant_idx = i
|
|
|
|
|
- break
|
|
|
|
|
-
|
|
|
|
|
- # 如果没有 assistant message,说明还没开始对话,不优化
|
|
|
|
|
- if last_assistant_idx == -1:
|
|
|
|
|
- return messages
|
|
|
|
|
-
|
|
|
|
|
- # 统计从每个位置到最后一条 assistant 之间的 assistant 数量(作为"轮次")
|
|
|
|
|
- assistant_count_after = [0] * len(messages)
|
|
|
|
|
- count = 0
|
|
|
|
|
- for i in range(len(messages) - 1, -1, -1):
|
|
|
|
|
- assistant_count_after[i] = count
|
|
|
|
|
- if messages[i].get("role") == "assistant":
|
|
|
|
|
- count += 1
|
|
|
|
|
-
|
|
|
|
|
- # 深拷贝避免修改原始数据
|
|
|
|
|
- import copy
|
|
|
|
|
- import hashlib
|
|
|
|
|
- import asyncio
|
|
|
|
|
- import base64 as b64mod
|
|
|
|
|
- import httpx
|
|
|
|
|
- import mimetypes
|
|
|
|
|
-
|
|
|
|
|
- messages = copy.deepcopy(messages)
|
|
|
|
|
-
|
|
|
|
|
- # 预处理:将所有 HTTP(S) URL 图片下载并转为 base64 data URL
|
|
|
|
|
- # Qwen API 无法访问外部签名 URL(如 BFL、火山引擎 TOS),必须在本地转换
|
|
|
|
|
- url_download_jobs = [] # [(msg_idx, block_idx, url)]
|
|
|
|
|
- for i, msg in enumerate(messages):
|
|
|
|
|
- if msg.get("role") != "tool":
|
|
|
|
|
- continue
|
|
|
|
|
- content = msg.get("content")
|
|
|
|
|
- if not isinstance(content, list):
|
|
|
|
|
- continue
|
|
|
|
|
- for block_idx, block in enumerate(content):
|
|
|
|
|
- if isinstance(block, dict) and block.get("type") == "image_url":
|
|
|
|
|
- url = block.get("image_url", {}).get("url", "")
|
|
|
|
|
- if url.startswith(("http://", "https://")):
|
|
|
|
|
- url_download_jobs.append((i, block_idx, url))
|
|
|
|
|
-
|
|
|
|
|
- if url_download_jobs:
|
|
|
|
|
-
|
|
|
|
|
- async def _download_image_to_data_url(url: str) -> str | None:
|
|
|
|
|
- try:
|
|
|
|
|
- async with httpx.AsyncClient(timeout=60, trust_env=False) as client:
|
|
|
|
|
- resp = await client.get(url)
|
|
|
|
|
- resp.raise_for_status()
|
|
|
|
|
- ct = resp.headers.get("content-type", "").split(";")[0].strip()
|
|
|
|
|
- if not ct.startswith("image/"):
|
|
|
|
|
- ct = (
|
|
|
|
|
- mimetypes.guess_type(url.split("?")[0])[0]
|
|
|
|
|
- or "image/png"
|
|
|
|
|
- )
|
|
|
|
|
- b64 = b64mod.b64encode(resp.content).decode()
|
|
|
|
|
- return f"data:{ct};base64,{b64}"
|
|
|
|
|
- except Exception:
|
|
|
|
|
- return None
|
|
|
|
|
-
|
|
|
|
|
- results = await asyncio.gather(
|
|
|
|
|
- *[_download_image_to_data_url(url) for _, _, url in url_download_jobs],
|
|
|
|
|
- return_exceptions=True,
|
|
|
|
|
- )
|
|
|
|
|
- converted = 0
|
|
|
|
|
- for (msg_idx, block_idx, original_url), result in zip(
|
|
|
|
|
- url_download_jobs, results
|
|
|
|
|
- ):
|
|
|
|
|
- if isinstance(result, str) and result.startswith("data:"):
|
|
|
|
|
- messages[msg_idx]["content"][block_idx]["image_url"]["url"] = result
|
|
|
|
|
- converted += 1
|
|
|
|
|
- if converted:
|
|
|
|
|
- self.log.info(
|
|
|
|
|
- f"[Image Optimization] URL→base64 预转换: {converted}/{len(url_download_jobs)} 张"
|
|
|
|
|
- )
|
|
|
|
|
-
|
|
|
|
|
- # 统计优化情况
|
|
|
|
|
- stats = {"kept": 0, "downscaled": 0, "described": 0, "cache_hit": 0}
|
|
|
|
|
-
|
|
|
|
|
- # 收集需要降分辨率或尺寸补齐的图片(用于并发处理)
|
|
|
|
|
- process_jobs = [] # [(msg_idx, block_idx, image_url, cache_key, max_size, cache_field)]
|
|
|
|
|
-
|
|
|
|
|
- # 第一遍:扫描并收集需要处理的图片
|
|
|
|
|
- for i in range(last_assistant_idx):
|
|
|
|
|
- msg = messages[i]
|
|
|
|
|
- if msg.get("role") != "tool":
|
|
|
|
|
- continue
|
|
|
|
|
-
|
|
|
|
|
- content = msg.get("content")
|
|
|
|
|
- if not isinstance(content, list):
|
|
|
|
|
- continue
|
|
|
|
|
-
|
|
|
|
|
- rounds_ago = assistant_count_after[i]
|
|
|
|
|
-
|
|
|
|
|
- for block_idx, block in enumerate(content):
|
|
|
|
|
- if isinstance(block, dict) and block.get("type") == "image_url":
|
|
|
|
|
- image_url_obj = block.get("image_url", {})
|
|
|
|
|
- image_url = image_url_obj.get("url", "")
|
|
|
|
|
-
|
|
|
|
|
- if image_url.startswith("data:"):
|
|
|
|
|
- cache_key = hashlib.md5(image_url[:200].encode()).hexdigest()
|
|
|
|
|
- else:
|
|
|
|
|
- cache_key = hashlib.md5(image_url.encode()).hexdigest()
|
|
|
|
|
-
|
|
|
|
|
- # 1-5 轮都需要检查尺寸
|
|
|
|
|
- if rounds_ago <= 5:
|
|
|
|
|
- cached = self._image_opt_cache.get(cache_key, {})
|
|
|
|
|
- cache_field = "pad_only" if rounds_ago <= 2 else "downscaled"
|
|
|
|
|
-
|
|
|
|
|
- if cache_field not in cached and image_url.startswith("data:"):
|
|
|
|
|
- max_size = None if rounds_ago <= 2 else 512
|
|
|
|
|
- process_jobs.append(
|
|
|
|
|
- (
|
|
|
|
|
- i,
|
|
|
|
|
- block_idx,
|
|
|
|
|
- image_url,
|
|
|
|
|
- cache_key,
|
|
|
|
|
- max_size,
|
|
|
|
|
- cache_field,
|
|
|
|
|
- )
|
|
|
|
|
- )
|
|
|
|
|
-
|
|
|
|
|
- # 并发处理所有尺寸任务
|
|
|
|
|
- if process_jobs:
|
|
|
|
|
- process_results = await asyncio.gather(
|
|
|
|
|
- *[
|
|
|
|
|
- self._process_image_size(url, max_size=ms)
|
|
|
|
|
- for _, _, url, _, ms, _ in process_jobs
|
|
|
|
|
- ],
|
|
|
|
|
- return_exceptions=True,
|
|
|
|
|
- )
|
|
|
|
|
- for (_, _, _, cache_key, _, cache_field), result in zip(
|
|
|
|
|
- process_jobs, process_results
|
|
|
|
|
- ):
|
|
|
|
|
- if not isinstance(result, Exception) and result is not None:
|
|
|
|
|
- self._image_opt_cache.setdefault(cache_key, {})[cache_field] = (
|
|
|
|
|
- result
|
|
|
|
|
- )
|
|
|
|
|
-
|
|
|
|
|
- # 第二遍:应用处理结果
|
|
|
|
|
- for i in range(last_assistant_idx):
|
|
|
|
|
- msg = messages[i]
|
|
|
|
|
- if msg.get("role") != "tool":
|
|
|
|
|
- continue
|
|
|
|
|
-
|
|
|
|
|
- content = msg.get("content")
|
|
|
|
|
- if not isinstance(content, list):
|
|
|
|
|
- continue
|
|
|
|
|
-
|
|
|
|
|
- # 计算这条消息距离最后一条 assistant 的"轮次"
|
|
|
|
|
- rounds_ago = assistant_count_after[i]
|
|
|
|
|
-
|
|
|
|
|
- # 处理每个 content block
|
|
|
|
|
- new_content = []
|
|
|
|
|
- for block in content:
|
|
|
|
|
- if isinstance(block, dict) and block.get("type") == "image_url":
|
|
|
|
|
- image_url_obj = block.get("image_url", {})
|
|
|
|
|
- image_url = image_url_obj.get("url", "")
|
|
|
|
|
-
|
|
|
|
|
- # 生成缓存 key(URL 图片用 URL 本身,base64 用前 64 字符 hash)
|
|
|
|
|
- if image_url.startswith("data:"):
|
|
|
|
|
- cache_key = hashlib.md5(image_url[:200].encode()).hexdigest()
|
|
|
|
|
- else:
|
|
|
|
|
- cache_key = hashlib.md5(image_url.encode()).hexdigest()
|
|
|
|
|
-
|
|
|
|
|
- # 根据距离决定处理策略
|
|
|
|
|
- if rounds_ago <= 2:
|
|
|
|
|
- # 最近 1-2 轮:只补齐过小图片,保留原分辨率
|
|
|
|
|
- cached = self._image_opt_cache.get(cache_key, {})
|
|
|
|
|
- if "pad_only" in cached:
|
|
|
|
|
- new_content.append(
|
|
|
|
|
- {
|
|
|
|
|
- "type": "image_url",
|
|
|
|
|
- "image_url": {"url": cached["pad_only"]},
|
|
|
|
|
- }
|
|
|
|
|
- )
|
|
|
|
|
- stats["kept"] += 1
|
|
|
|
|
- stats["cache_hit"] += 1
|
|
|
|
|
- elif image_url.startswith("data:"):
|
|
|
|
|
- processed = await self._process_image_size(
|
|
|
|
|
- image_url, max_size=None
|
|
|
|
|
- )
|
|
|
|
|
- if processed:
|
|
|
|
|
- self._image_opt_cache.setdefault(cache_key, {})[
|
|
|
|
|
- "pad_only"
|
|
|
|
|
- ] = processed
|
|
|
|
|
- new_content.append(
|
|
|
|
|
- {
|
|
|
|
|
- "type": "image_url",
|
|
|
|
|
- "image_url": {"url": processed},
|
|
|
|
|
- }
|
|
|
|
|
- )
|
|
|
|
|
- else:
|
|
|
|
|
- new_content.append(block)
|
|
|
|
|
- stats["kept"] += 1
|
|
|
|
|
- else:
|
|
|
|
|
- new_content.append(block)
|
|
|
|
|
- stats["kept"] += 1
|
|
|
|
|
-
|
|
|
|
|
- elif rounds_ago <= 5:
|
|
|
|
|
- # 3-5 轮:降低分辨率(优先从缓存取)
|
|
|
|
|
- cached = self._image_opt_cache.get(cache_key, {})
|
|
|
|
|
- if "downscaled" in cached:
|
|
|
|
|
- new_content.append(
|
|
|
|
|
- {
|
|
|
|
|
- "type": "image_url",
|
|
|
|
|
- "image_url": {"url": cached["downscaled"]},
|
|
|
|
|
- }
|
|
|
|
|
- )
|
|
|
|
|
- stats["downscaled"] += 1
|
|
|
|
|
- stats["cache_hit"] += 1
|
|
|
|
|
- elif image_url.startswith("data:"):
|
|
|
|
|
- processed = await self._process_image_size(
|
|
|
|
|
- image_url, max_size=512
|
|
|
|
|
- )
|
|
|
|
|
- if processed:
|
|
|
|
|
- # 缓存结果
|
|
|
|
|
- self._image_opt_cache.setdefault(cache_key, {})[
|
|
|
|
|
- "downscaled"
|
|
|
|
|
- ] = processed
|
|
|
|
|
- new_content.append(
|
|
|
|
|
- {
|
|
|
|
|
- "type": "image_url",
|
|
|
|
|
- "image_url": {"url": processed},
|
|
|
|
|
- }
|
|
|
|
|
- )
|
|
|
|
|
- stats["downscaled"] += 1
|
|
|
|
|
- else:
|
|
|
|
|
- new_content.append(block)
|
|
|
|
|
- stats["kept"] += 1
|
|
|
|
|
- else:
|
|
|
|
|
- # URL 图片:无法直接处理,保留原图
|
|
|
|
|
- new_content.append(block)
|
|
|
|
|
- stats["kept"] += 1
|
|
|
|
|
-
|
|
|
|
|
- else:
|
|
|
|
|
- # 5 轮以上:生成文本描述(优先从缓存取)
|
|
|
|
|
- cached = self._image_opt_cache.get(cache_key, {})
|
|
|
|
|
- if "description" in cached:
|
|
|
|
|
- new_content.append(cached["description"])
|
|
|
|
|
- stats["described"] += 1
|
|
|
|
|
- stats["cache_hit"] += 1
|
|
|
|
|
- else:
|
|
|
|
|
- description = await self._generate_image_description(
|
|
|
|
|
- image_url, model
|
|
|
|
|
- )
|
|
|
|
|
- url_info = (
|
|
|
|
|
- f" (URL: {image_url[:100]}...)"
|
|
|
|
|
- if not image_url.startswith("data:")
|
|
|
|
|
- else ""
|
|
|
|
|
- )
|
|
|
|
|
- desc_block = {
|
|
|
|
|
- "type": "text",
|
|
|
|
|
- "text": f"[Image description: {description}]{url_info}",
|
|
|
|
|
- }
|
|
|
|
|
- # 缓存结果
|
|
|
|
|
- self._image_opt_cache.setdefault(cache_key, {})[
|
|
|
|
|
- "description"
|
|
|
|
|
- ] = desc_block
|
|
|
|
|
- new_content.append(desc_block)
|
|
|
|
|
- stats["described"] += 1
|
|
|
|
|
- else:
|
|
|
|
|
- new_content.append(block)
|
|
|
|
|
-
|
|
|
|
|
- msg["content"] = new_content
|
|
|
|
|
- # print(f"[Image Opt Check] 扫描到 {stats['kept'] + stats['downscaled'] + stats['described']} 张图片上下文")
|
|
|
|
|
- if stats["downscaled"] > 0 or stats["described"] > 0:
|
|
|
|
|
- self.log.info(
|
|
|
|
|
- f"[Image Optimization] 保留 {stats['kept']} 张,"
|
|
|
|
|
- f"降分辨率 {stats['downscaled']} 张,"
|
|
|
|
|
- f"文本描述 {stats['described']} 张,"
|
|
|
|
|
- f"缓存命中 {stats['cache_hit']} 次"
|
|
|
|
|
- )
|
|
|
|
|
-
|
|
|
|
|
- return messages
|
|
|
|
|
|
|
+ return await self._image_runtime._optimize_images(messages, model)
|
|
|
|
|
|
|
|
async def _process_image_size(
|
|
async def _process_image_size(
|
|
|
self, base64_url: str, max_size: Optional[int] = 512, min_size: int = 11
|
|
self, base64_url: str, max_size: Optional[int] = 512, min_size: int = 11
|
|
|
) -> Optional[str]:
|
|
) -> Optional[str]:
|
|
|
- """
|
|
|
|
|
- 处理 base64 图片的尺寸:
|
|
|
|
|
- - 若 max_size 不为 None 且大于该值,则等比例缩放
|
|
|
|
|
- - 若任意一边小于 min_size,则补充白边 (Padding)
|
|
|
|
|
- """
|
|
|
|
|
- try:
|
|
|
|
|
- from PIL import Image
|
|
|
|
|
- import io
|
|
|
|
|
- import base64
|
|
|
|
|
-
|
|
|
|
|
- # 解析 base64 数据
|
|
|
|
|
- if not base64_url.startswith("data:"):
|
|
|
|
|
- return None
|
|
|
|
|
-
|
|
|
|
|
- _header, data = base64_url.split(",", 1)
|
|
|
|
|
-
|
|
|
|
|
- # 解码图片
|
|
|
|
|
- img_data = base64.b64decode(data)
|
|
|
|
|
- img = Image.open(io.BytesIO(img_data))
|
|
|
|
|
-
|
|
|
|
|
- width, height = img.size
|
|
|
|
|
-
|
|
|
|
|
- needs_downscale = max_size is not None and (
|
|
|
|
|
- width > max_size or height > max_size
|
|
|
|
|
- )
|
|
|
|
|
- needs_pad = width < min_size or height < min_size
|
|
|
|
|
-
|
|
|
|
|
- # 尺寸正常,无需处理
|
|
|
|
|
- if not needs_downscale and not needs_pad:
|
|
|
|
|
- return base64_url
|
|
|
|
|
-
|
|
|
|
|
- new_width, new_height = width, height
|
|
|
|
|
-
|
|
|
|
|
- # 1. 降分辨率
|
|
|
|
|
- if needs_downscale:
|
|
|
|
|
- if width > height:
|
|
|
|
|
- new_width = max_size
|
|
|
|
|
- new_height = int(height * max_size / width)
|
|
|
|
|
- else:
|
|
|
|
|
- new_height = max_size
|
|
|
|
|
- new_width = int(width * max_size / height)
|
|
|
|
|
-
|
|
|
|
|
- if (new_width, new_height) != (width, height):
|
|
|
|
|
- img_resized = img.resize(
|
|
|
|
|
- (new_width, new_height), Image.Resampling.BILINEAR
|
|
|
|
|
- )
|
|
|
|
|
- else:
|
|
|
|
|
- img_resized = img
|
|
|
|
|
-
|
|
|
|
|
- # 2. 补齐白边 (Padding)
|
|
|
|
|
- pad_width = max(new_width, min_size)
|
|
|
|
|
- pad_height = max(new_height, min_size)
|
|
|
|
|
-
|
|
|
|
|
- if pad_width > new_width or pad_height > new_height:
|
|
|
|
|
- # 创建白色背景
|
|
|
|
|
- padded_img = Image.new(
|
|
|
|
|
- "RGBA" if img_resized.mode in ("RGBA", "P") else "RGB",
|
|
|
|
|
- (pad_width, pad_height),
|
|
|
|
|
- (255, 255, 255, 255),
|
|
|
|
|
- )
|
|
|
|
|
- offset_x = (pad_width - new_width) // 2
|
|
|
|
|
- offset_y = (pad_height - new_height) // 2
|
|
|
|
|
- padded_img.paste(img_resized, (offset_x, offset_y))
|
|
|
|
|
- img_resized = padded_img
|
|
|
|
|
-
|
|
|
|
|
- # 转换为 RGB(JPEG不支持 RGBA, P 等具有透明度或索引的模式)
|
|
|
|
|
- if img_resized.mode != "RGB":
|
|
|
|
|
- if img_resized.mode == "RGBA" or img_resized.mode == "P":
|
|
|
|
|
- # Create a white background for transparent images
|
|
|
|
|
- background = Image.new("RGB", img_resized.size, (255, 255, 255))
|
|
|
|
|
- if img_resized.mode == "P" and "transparency" in img_resized.info:
|
|
|
|
|
- img_resized = img_resized.convert("RGBA")
|
|
|
|
|
- if img_resized.mode == "RGBA":
|
|
|
|
|
- background.paste(img_resized, mask=img_resized.split()[3])
|
|
|
|
|
- img_resized = background
|
|
|
|
|
- img_resized = img_resized.convert("RGB")
|
|
|
|
|
-
|
|
|
|
|
- # 重新编码为 JPEG(如果只是补齐没有缩放,可以稍微保留高点质量)
|
|
|
|
|
- buffer = io.BytesIO()
|
|
|
|
|
- quality = 60 if needs_downscale else 85
|
|
|
|
|
- img_resized.save(buffer, format="JPEG", quality=quality, optimize=False)
|
|
|
|
|
- new_data = base64.b64encode(buffer.getvalue()).decode("utf-8")
|
|
|
|
|
-
|
|
|
|
|
- return f"data:image/jpeg;base64,{new_data}"
|
|
|
|
|
-
|
|
|
|
|
- except Exception as e:
|
|
|
|
|
- self.log.warning(f"[Image Process] 处理图片尺寸失败: {e}")
|
|
|
|
|
- return None
|
|
|
|
|
|
|
+ return await self._image_runtime._process_image_size(
|
|
|
|
|
+ base64_url,
|
|
|
|
|
+ max_size=max_size,
|
|
|
|
|
+ min_size=min_size,
|
|
|
|
|
+ )
|
|
|
|
|
|
|
|
async def _generate_image_description(
|
|
async def _generate_image_description(
|
|
|
self, image_url: str, current_model: str
|
|
self, image_url: str, current_model: str
|
|
|
) -> str:
|
|
) -> str:
|
|
|
- """
|
|
|
|
|
- 使用小模型生成图片的文本描述
|
|
|
|
|
-
|
|
|
|
|
- Args:
|
|
|
|
|
- image_url: 图片 URL(base64 或 http(s))
|
|
|
|
|
- current_model: 当前使用的模型
|
|
|
|
|
-
|
|
|
|
|
- Returns:
|
|
|
|
|
- 图片描述文本
|
|
|
|
|
- """
|
|
|
|
|
- try:
|
|
|
|
|
- # 使用 qwen-vl-max(通义千问视觉模型)生成描述
|
|
|
|
|
- # 注意:qwen-vl 系列专门支持视觉输入
|
|
|
|
|
- description_model = "qwen-vl-max"
|
|
|
|
|
-
|
|
|
|
|
- # 构建描述请求
|
|
|
|
|
- messages = [
|
|
|
|
|
- {
|
|
|
|
|
- "role": "user",
|
|
|
|
|
- "content": [
|
|
|
|
|
- {"type": "image_url", "image_url": {"url": image_url}},
|
|
|
|
|
- {
|
|
|
|
|
- "type": "text",
|
|
|
|
|
- "text": "请用 1-2 句话简洁描述这张图片的主要内容。",
|
|
|
|
|
- },
|
|
|
|
|
- ],
|
|
|
|
|
- }
|
|
|
|
|
- ]
|
|
|
|
|
-
|
|
|
|
|
- # 调用 LLM
|
|
|
|
|
- result = await self.llm_call(
|
|
|
|
|
- messages=messages,
|
|
|
|
|
- model=description_model,
|
|
|
|
|
- tools=None,
|
|
|
|
|
- temperature=0.3,
|
|
|
|
|
- )
|
|
|
|
|
-
|
|
|
|
|
- description = result.get("content", "").strip()
|
|
|
|
|
- return description if description else "图片内容"
|
|
|
|
|
-
|
|
|
|
|
- except Exception as e:
|
|
|
|
|
- self.log.warning(f"[Image Description] 生成描述失败: {e}")
|
|
|
|
|
- return "图片内容"
|
|
|
|
|
|
|
+ return await self._image_runtime._generate_image_description(
|
|
|
|
|
+ image_url,
|
|
|
|
|
+ current_model,
|
|
|
|
|
+ )
|
|
|
|
|
|
|
|
def _add_cache_control(
|
|
def _add_cache_control(
|
|
|
self, messages: List[Dict], model: str, enable: bool
|
|
self, messages: List[Dict], model: str, enable: bool
|
|
|
) -> List[Dict]:
|
|
) -> List[Dict]:
|
|
|
- """
|
|
|
|
|
- 为支持的模型添加 Prompt Caching 标记
|
|
|
|
|
-
|
|
|
|
|
- 策略:固定位置 + 延迟缓存
|
|
|
|
|
- 1. 如果有未处理的图片(最后一条 assistant 之后的 tool messages 中有图片),跳过缓存
|
|
|
|
|
- 2. system message 添加缓存(如果足够长)
|
|
|
|
|
- 3. 固定位置缓存点(20, 40, 60, 80),确保每个缓存点间隔 >= 1024 tokens
|
|
|
|
|
- 4. 最多使用 4 个缓存点(含 system)
|
|
|
|
|
-
|
|
|
|
|
- Args:
|
|
|
|
|
- messages: 原始消息列表
|
|
|
|
|
- model: 模型名称
|
|
|
|
|
- enable: 是否启用缓存
|
|
|
|
|
-
|
|
|
|
|
- Returns:
|
|
|
|
|
- 添加了 cache_control 的消息列表(深拷贝)
|
|
|
|
|
- """
|
|
|
|
|
- if not enable:
|
|
|
|
|
- return messages
|
|
|
|
|
-
|
|
|
|
|
- # 只对 Claude 模型启用
|
|
|
|
|
- if "claude" not in model.lower():
|
|
|
|
|
- return messages
|
|
|
|
|
-
|
|
|
|
|
- # 延迟缓存:检查是否有未处理的图片
|
|
|
|
|
- last_assistant_idx = -1
|
|
|
|
|
- for i in range(len(messages) - 1, -1, -1):
|
|
|
|
|
- if messages[i].get("role") == "assistant":
|
|
|
|
|
- last_assistant_idx = i
|
|
|
|
|
- break
|
|
|
|
|
-
|
|
|
|
|
- # 检查最后一条 assistant 之后是否有包含图片的 tool messages
|
|
|
|
|
- has_unprocessed_images = False
|
|
|
|
|
- if last_assistant_idx >= 0:
|
|
|
|
|
- for i in range(last_assistant_idx + 1, len(messages)):
|
|
|
|
|
- msg = messages[i]
|
|
|
|
|
- if msg.get("role") == "tool":
|
|
|
|
|
- content = msg.get("content")
|
|
|
|
|
- if isinstance(content, list):
|
|
|
|
|
- has_unprocessed_images = any(
|
|
|
|
|
- isinstance(block, dict) and block.get("type") == "image_url"
|
|
|
|
|
- for block in content
|
|
|
|
|
- )
|
|
|
|
|
- if has_unprocessed_images:
|
|
|
|
|
- break
|
|
|
|
|
-
|
|
|
|
|
- if has_unprocessed_images:
|
|
|
|
|
- self.log.debug("[Cache] 检测到未处理的图片,延迟缓存建立")
|
|
|
|
|
- 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", "")
|
|
|
|
|
- if isinstance(content, str) and len(content) > 1000:
|
|
|
|
|
- msg["content"] = [
|
|
|
|
|
- {
|
|
|
|
|
- "type": "text",
|
|
|
|
|
- "text": content,
|
|
|
|
|
- "cache_control": {"type": "ephemeral"},
|
|
|
|
|
- }
|
|
|
|
|
- ]
|
|
|
|
|
- system_cached = True
|
|
|
|
|
- self.log.debug(
|
|
|
|
|
- f"[Cache] 为 system message 添加缓存标记 (len={len(content)})"
|
|
|
|
|
- )
|
|
|
|
|
- break
|
|
|
|
|
-
|
|
|
|
|
- # 策略 2: 固定位置缓存点
|
|
|
|
|
- CACHE_INTERVAL = 20
|
|
|
|
|
- MAX_POINTS = 3 if system_cached else 4
|
|
|
|
|
- MIN_TOKENS = 1024
|
|
|
|
|
- AVG_TOKENS_PER_MSG = 70
|
|
|
|
|
-
|
|
|
|
|
- total_msgs = len(messages)
|
|
|
|
|
- if total_msgs == 0:
|
|
|
|
|
- return messages
|
|
|
|
|
-
|
|
|
|
|
- cache_positions = []
|
|
|
|
|
- last_cache_pos = 0
|
|
|
|
|
-
|
|
|
|
|
- for i in range(1, MAX_POINTS + 1):
|
|
|
|
|
- target_pos = i * CACHE_INTERVAL - 1 # 19, 39, 59, 79
|
|
|
|
|
-
|
|
|
|
|
- if target_pos >= total_msgs:
|
|
|
|
|
- break
|
|
|
|
|
-
|
|
|
|
|
- # 从目标位置开始查找合适的 user/assistant 消息
|
|
|
|
|
- for j in range(target_pos, total_msgs):
|
|
|
|
|
- msg = messages[j]
|
|
|
|
|
-
|
|
|
|
|
- if msg.get("role") not in ("user", "assistant"):
|
|
|
|
|
- continue
|
|
|
|
|
-
|
|
|
|
|
- content = msg.get("content", "")
|
|
|
|
|
- if not content:
|
|
|
|
|
- continue
|
|
|
|
|
-
|
|
|
|
|
- # 检查 content 是否非空
|
|
|
|
|
- is_valid = False
|
|
|
|
|
- if isinstance(content, str):
|
|
|
|
|
- is_valid = len(content) > 0
|
|
|
|
|
- elif isinstance(content, list):
|
|
|
|
|
- is_valid = any(
|
|
|
|
|
- isinstance(block, dict)
|
|
|
|
|
- and block.get("type") == "text"
|
|
|
|
|
- and len(block.get("text", "")) > 0
|
|
|
|
|
- for block in content
|
|
|
|
|
- )
|
|
|
|
|
-
|
|
|
|
|
- if not is_valid:
|
|
|
|
|
- continue
|
|
|
|
|
-
|
|
|
|
|
- # 检查 token 距离
|
|
|
|
|
- msg_count = j - last_cache_pos
|
|
|
|
|
- estimated_tokens = msg_count * AVG_TOKENS_PER_MSG
|
|
|
|
|
-
|
|
|
|
|
- if estimated_tokens >= MIN_TOKENS:
|
|
|
|
|
- cache_positions.append(j)
|
|
|
|
|
- last_cache_pos = j
|
|
|
|
|
- self.log.debug(
|
|
|
|
|
- f"[Cache] 在位置 {j} 添加缓存点 (估算 {estimated_tokens} tokens)"
|
|
|
|
|
- )
|
|
|
|
|
- break
|
|
|
|
|
-
|
|
|
|
|
- # 应用缓存标记
|
|
|
|
|
- for idx in cache_positions:
|
|
|
|
|
- msg = messages[idx]
|
|
|
|
|
- content = msg.get("content", "")
|
|
|
|
|
-
|
|
|
|
|
- if isinstance(content, str):
|
|
|
|
|
- msg["content"] = [
|
|
|
|
|
- {
|
|
|
|
|
- "type": "text",
|
|
|
|
|
- "text": content,
|
|
|
|
|
- "cache_control": {"type": "ephemeral"},
|
|
|
|
|
- }
|
|
|
|
|
- ]
|
|
|
|
|
- self.log.debug(
|
|
|
|
|
- f"[Cache] 为 message[{idx}] ({msg.get('role')}) 添加缓存标记"
|
|
|
|
|
- )
|
|
|
|
|
- elif isinstance(content, list):
|
|
|
|
|
- # 在最后一个 text block 添加 cache_control
|
|
|
|
|
- for block in reversed(content):
|
|
|
|
|
- if isinstance(block, dict) and block.get("type") == "text":
|
|
|
|
|
- block["cache_control"] = {"type": "ephemeral"}
|
|
|
|
|
- self.log.debug(
|
|
|
|
|
- f"[Cache] 为 message[{idx}] ({msg.get('role')}) 添加缓存标记"
|
|
|
|
|
- )
|
|
|
|
|
- break
|
|
|
|
|
-
|
|
|
|
|
- self.log.debug(
|
|
|
|
|
- f"[Cache] 总消息: {total_msgs}, "
|
|
|
|
|
- f"缓存点: {len(cache_positions)} at {cache_positions}"
|
|
|
|
|
- )
|
|
|
|
|
- return messages
|
|
|
|
|
|
|
+ return self._image_runtime._add_cache_control(messages, model, enable)
|
|
|
|
|
|
|
|
def _resolve_run_policy(
|
|
def _resolve_run_policy(
|
|
|
self,
|
|
self,
|
|
|
config: RunConfig,
|
|
config: RunConfig,
|
|
|
trace: Optional[Trace] = None,
|
|
trace: Optional[Trace] = None,
|
|
|
) -> ResolvedAgentPolicy:
|
|
) -> ResolvedAgentPolicy:
|
|
|
- """Resolve role and effective tools from the preset, never caller context."""
|
|
|
|
|
- preset = get_preset(config.agent_type)
|
|
|
|
|
- policy = self.tool_policy.resolve(config, preset, self.tools)
|
|
|
|
|
- if trace and trace.agent_role != policy.role.value:
|
|
|
|
|
- raise ValueError(
|
|
|
|
|
- f"Trace role mismatch: stored={trace.agent_role}, requested={policy.role.value}"
|
|
|
|
|
- )
|
|
|
|
|
- if policy.completion_policy == CompletionPolicy.EXPLICIT_VALIDATION:
|
|
|
|
|
- if policy.role == AgentRole.LEGACY:
|
|
|
|
|
- raise ValueError(
|
|
|
|
|
- f"Preset '{config.agent_type}' has legacy role and cannot run explicit_validation"
|
|
|
|
|
- )
|
|
|
|
|
- return policy
|
|
|
|
|
|
|
+ return self._tool_runtime.resolve_policy(config, trace)
|
|
|
|
|
|
|
|
def _get_run_tool_schemas(
|
|
def _get_run_tool_schemas(
|
|
|
self,
|
|
self,
|
|
|
config: RunConfig,
|
|
config: RunConfig,
|
|
|
policy: Optional[ResolvedAgentPolicy] = None,
|
|
policy: Optional[ResolvedAgentPolicy] = None,
|
|
|
) -> List[Dict]:
|
|
) -> List[Dict]:
|
|
|
- policy = policy or self._resolve_run_policy(config)
|
|
|
|
|
- if policy.completion_policy == CompletionPolicy.LEGACY_AUTO:
|
|
|
|
|
- schemas = self._get_tool_schemas(
|
|
|
|
|
- config.tools, config.tool_groups, config.exclude_tools
|
|
|
|
|
- )
|
|
|
|
|
- explicit_only = PLANNER_TOOLS | WORKER_TOOLS | VALIDATOR_TOOLS
|
|
|
|
|
- return [
|
|
|
|
|
- schema
|
|
|
|
|
- for schema in schemas
|
|
|
|
|
- if schema.get("function", {}).get("name") not in explicit_only
|
|
|
|
|
- ]
|
|
|
|
|
- return self.tools.get_schemas(sorted(policy.effective_tools))
|
|
|
|
|
|
|
+ return self._tool_runtime.get_run_tool_schemas(
|
|
|
|
|
+ config,
|
|
|
|
|
+ policy or self._resolve_run_policy(config),
|
|
|
|
|
+ get_tool_schemas=self._get_tool_schemas,
|
|
|
|
|
+ )
|
|
|
|
|
|
|
|
def _build_protected_tool_context(
|
|
def _build_protected_tool_context(
|
|
|
self,
|
|
self,
|
|
@@ -3832,31 +3226,16 @@ class AgentRunner:
|
|
|
sequence: int,
|
|
sequence: int,
|
|
|
side_branch: Optional[Dict[str, Any]] = None,
|
|
side_branch: Optional[Dict[str, Any]] = None,
|
|
|
) -> Dict[str, Any]:
|
|
) -> Dict[str, Any]:
|
|
|
- """Merge host context first; framework identity fields always win."""
|
|
|
|
|
- context = dict(config.context or {})
|
|
|
|
|
- trace_context = trace.context or {}
|
|
|
|
|
- context.update(
|
|
|
|
|
- {
|
|
|
|
|
- "store": self.trace_store,
|
|
|
|
|
- "trace_id": trace.trace_id,
|
|
|
|
|
- "root_trace_id": trace_context.get("root_trace_id", trace.trace_id),
|
|
|
|
|
- "goal_id": goal_tree.current_id if goal_tree else None,
|
|
|
|
|
- "runner": self,
|
|
|
|
|
- "goal_tree": goal_tree,
|
|
|
|
|
- "knowledge_config": config.knowledge,
|
|
|
|
|
- "sequence": sequence,
|
|
|
|
|
- "side_branch": side_branch,
|
|
|
|
|
- "role": trace.agent_role,
|
|
|
|
|
- "completion_policy": CompletionPolicy(config.completion_policy).value,
|
|
|
|
|
- "task_id": trace_context.get("task_id"),
|
|
|
|
|
- "spec_version": trace_context.get("spec_version"),
|
|
|
|
|
- "attempt_id": trace_context.get("attempt_id"),
|
|
|
|
|
- "snapshot_id": trace_context.get("snapshot_id"),
|
|
|
|
|
- "validation_id": trace_context.get("validation_id"),
|
|
|
|
|
- "coordinator": self.task_coordinator,
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ return self._tool_runtime.build_protected_context(
|
|
|
|
|
+ config,
|
|
|
|
|
+ trace,
|
|
|
|
|
+ goal_tree,
|
|
|
|
|
+ sequence,
|
|
|
|
|
+ runner=self,
|
|
|
|
|
+ trace_store=self.trace_store,
|
|
|
|
|
+ task_coordinator=self.task_coordinator,
|
|
|
|
|
+ side_branch=side_branch,
|
|
|
)
|
|
)
|
|
|
- return context
|
|
|
|
|
|
|
|
|
|
async def _execute_authorized_tool(
|
|
async def _execute_authorized_tool(
|
|
|
self,
|
|
self,
|
|
@@ -3869,26 +3248,17 @@ class AgentRunner:
|
|
|
sequence: int,
|
|
sequence: int,
|
|
|
side_branch: Optional[Dict[str, Any]] = None,
|
|
side_branch: Optional[Dict[str, Any]] = None,
|
|
|
) -> Any:
|
|
) -> Any:
|
|
|
- policy = self._resolve_run_policy(config, trace)
|
|
|
|
|
- if policy.completion_policy == CompletionPolicy.EXPLICIT_VALIDATION:
|
|
|
|
|
- authorization = self.tool_policy.authorize(policy.role, tool_name, policy)
|
|
|
|
|
- if not authorization.allowed:
|
|
|
|
|
- self.log.warning(
|
|
|
|
|
- "Rejected unauthorized tool call: %s", authorization.reason
|
|
|
|
|
- )
|
|
|
|
|
- return {
|
|
|
|
|
- "text": f"Error: {authorization.reason}",
|
|
|
|
|
- "error": "unauthorized_tool",
|
|
|
|
|
- }
|
|
|
|
|
- context = self._build_protected_tool_context(
|
|
|
|
|
- config, trace, goal_tree, sequence, side_branch=side_branch
|
|
|
|
|
- )
|
|
|
|
|
- context["tool_call_id"] = tool_call_id
|
|
|
|
|
- return await self.tools.execute(
|
|
|
|
|
|
|
+ return await self._tool_runtime.execute_authorized_tool(
|
|
|
tool_name,
|
|
tool_name,
|
|
|
tool_args,
|
|
tool_args,
|
|
|
- uid=config.uid or "",
|
|
|
|
|
- context=context,
|
|
|
|
|
|
|
+ tool_call_id,
|
|
|
|
|
+ config,
|
|
|
|
|
+ trace,
|
|
|
|
|
+ goal_tree,
|
|
|
|
|
+ sequence,
|
|
|
|
|
+ resolve_policy=self._resolve_run_policy,
|
|
|
|
|
+ build_context=self._build_protected_tool_context,
|
|
|
|
|
+ side_branch=side_branch,
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
def _get_tool_schemas(
|
|
def _get_tool_schemas(
|
|
@@ -3897,28 +3267,11 @@ class AgentRunner:
|
|
|
tool_groups: Optional[List[str]] = None,
|
|
tool_groups: Optional[List[str]] = None,
|
|
|
exclude_tools: Optional[List[str]] = None,
|
|
exclude_tools: Optional[List[str]] = None,
|
|
|
) -> List[Dict]:
|
|
) -> List[Dict]:
|
|
|
- """
|
|
|
|
|
- 获取工具 Schema
|
|
|
|
|
-
|
|
|
|
|
- 合并策略(取并集):
|
|
|
|
|
- - tool_groups 非空: 按分组白名单过滤得到基础工具集
|
|
|
|
|
- - tools 非空: 追加指定的工具名(与 tool_groups 结果取并集)
|
|
|
|
|
- - 两者都为 None: 返回所有已注册工具
|
|
|
|
|
-
|
|
|
|
|
- 最后再用 exclude_tools 减去禁用的工具(如远程 agent 禁止 agent/evaluate)。
|
|
|
|
|
- """
|
|
|
|
|
- if tool_groups is not None:
|
|
|
|
|
- tool_names = set(self.tools.get_tool_names(groups=tool_groups))
|
|
|
|
|
- else:
|
|
|
|
|
- tool_names = set(self.tools.get_tool_names())
|
|
|
|
|
- if tools is not None:
|
|
|
|
|
- tool_names |= set(tools)
|
|
|
|
|
- if exclude_tools:
|
|
|
|
|
- tool_names -= set(exclude_tools)
|
|
|
|
|
- return self.tools.get_schemas(list(tool_names))
|
|
|
|
|
-
|
|
|
|
|
- # 默认 system prompt 前缀(当 config.system_prompt 和前端都未提供 system message 时使用)
|
|
|
|
|
- # 注意:此常量已迁移到 agent.core.prompts,这里保留引用以保持向后兼容
|
|
|
|
|
|
|
+ return self._tool_runtime.get_tool_schemas(
|
|
|
|
|
+ tools,
|
|
|
|
|
+ tool_groups,
|
|
|
|
|
+ exclude_tools,
|
|
|
|
|
+ )
|
|
|
|
|
|
|
|
async def _build_system_prompt(
|
|
async def _build_system_prompt(
|
|
|
self, config: RunConfig, base_prompt: Optional[str] = None
|
|
self, config: RunConfig, base_prompt: Optional[str] = None
|
|
@@ -3940,108 +3293,20 @@ class AgentRunner:
|
|
|
base_prompt: 已有 system 内容(来自消息),
|
|
base_prompt: 已有 system 内容(来自消息),
|
|
|
None 时使用 config.system_prompt 或 preset.system_prompt
|
|
None 时使用 config.system_prompt 或 preset.system_prompt
|
|
|
"""
|
|
"""
|
|
|
- from agent.core.presets import AGENT_PRESETS
|
|
|
|
|
-
|
|
|
|
|
- # 确定 system_prompt 来源
|
|
|
|
|
- if base_prompt is not None:
|
|
|
|
|
- system_prompt = base_prompt
|
|
|
|
|
- elif config.system_prompt is not None:
|
|
|
|
|
- system_prompt = config.system_prompt
|
|
|
|
|
- else:
|
|
|
|
|
- # 尝试从 preset 获取 system_prompt
|
|
|
|
|
- preset = AGENT_PRESETS.get(config.agent_type)
|
|
|
|
|
- system_prompt = (
|
|
|
|
|
- preset.system_prompt if preset and preset.system_prompt else None
|
|
|
|
|
- )
|
|
|
|
|
-
|
|
|
|
|
- # 确定要加载哪些 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_text = self._format_skills(skills) if skills else ""
|
|
|
|
|
-
|
|
|
|
|
- if system_prompt:
|
|
|
|
|
- if skills_text:
|
|
|
|
|
- system_prompt += f"\n\n## Skills\n{skills_text}"
|
|
|
|
|
- else:
|
|
|
|
|
- system_prompt = DEFAULT_SYSTEM_PREFIX
|
|
|
|
|
- if skills_text:
|
|
|
|
|
- system_prompt += f"\n\n## Skills\n{skills_text}"
|
|
|
|
|
-
|
|
|
|
|
- if config.max_iterations and config.max_iterations > 0:
|
|
|
|
|
- system_prompt += f"\n\n## Execution Constraint\n这是一项有严格步数限制的任务。你最多可以用 {config.max_iterations} 轮交互来解决问题。\n请务必【边查边写、随时存档】!每当你收集或得出一个有价值的独立结果(如收集到一个独立 Case),请立刻调用工具写入或追加到结果文件中,绝对不要等到所有任务都做完再最后一次性输出。这样即使触达步数上限被强制打断,你已经收集的成果也能安全保留!"
|
|
|
|
|
- # Memory 注入(memory-bearing Agent)——在 system prompt 末尾追加
|
|
|
|
|
- # 初版选择 system prompt 追加(见 agent/docs/memory.md 待定问题 1)。
|
|
|
|
|
- # 好处:run 启动一次性注入、所有后续轮次都能看到、与 skills 注入方式一致。
|
|
|
|
|
- # 代价:若记忆文件很大会持续占 prompt tokens —— 待观察后决定是否切换方案。
|
|
|
|
|
- if config.memory:
|
|
|
|
|
- try:
|
|
|
|
|
- from agent.core.memory import load_memory_files, format_memory_injection
|
|
|
|
|
-
|
|
|
|
|
- files = load_memory_files(config.memory)
|
|
|
|
|
- memory_text = format_memory_injection(files)
|
|
|
|
|
- if memory_text:
|
|
|
|
|
- system_prompt += f"\n\n{memory_text}"
|
|
|
|
|
- except Exception as e:
|
|
|
|
|
- self.log.warning(f"[Memory] 加载记忆失败,跳过注入: {e}")
|
|
|
|
|
-
|
|
|
|
|
- # 框架角色契约最后追加,Host prompt、skills 和 memory 都不能覆盖不变量。
|
|
|
|
|
- policy = self._resolve_run_policy(config)
|
|
|
|
|
- if policy.completion_policy == CompletionPolicy.EXPLICIT_VALIDATION:
|
|
|
|
|
- contract = ROLE_CONTRACTS.get(policy.role.value)
|
|
|
|
|
- if contract:
|
|
|
|
|
- system_prompt += f"\n\n{contract}"
|
|
|
|
|
-
|
|
|
|
|
- return system_prompt
|
|
|
|
|
|
|
+ return await self._prompt_runtime.build_system_prompt(
|
|
|
|
|
+ config,
|
|
|
|
|
+ base_prompt,
|
|
|
|
|
+ skills_dir=self.skills_dir,
|
|
|
|
|
+ resolve_policy=self._resolve_run_policy,
|
|
|
|
|
+ format_skills=self._format_skills,
|
|
|
|
|
+ )
|
|
|
|
|
|
|
|
async def _generate_task_name(self, messages: List[Dict]) -> str:
|
|
async def _generate_task_name(self, messages: List[Dict]) -> str:
|
|
|
"""生成任务名称:优先使用 utility_llm,fallback 到文本截取"""
|
|
"""生成任务名称:优先使用 utility_llm,fallback 到文本截取"""
|
|
|
- # 提取 messages 中的文本内容
|
|
|
|
|
- text_parts = []
|
|
|
|
|
- for msg in messages:
|
|
|
|
|
- content = msg.get("content", "")
|
|
|
|
|
- if isinstance(content, str):
|
|
|
|
|
- text_parts.append(content)
|
|
|
|
|
- elif isinstance(content, list):
|
|
|
|
|
- for part in content:
|
|
|
|
|
- if isinstance(part, dict) and part.get("type") == "text":
|
|
|
|
|
- text_parts.append(part.get("text", ""))
|
|
|
|
|
- raw_text = " ".join(text_parts).strip()
|
|
|
|
|
-
|
|
|
|
|
- if not raw_text:
|
|
|
|
|
- return TASK_NAME_FALLBACK
|
|
|
|
|
-
|
|
|
|
|
- # 尝试使用 utility_llm 生成标题
|
|
|
|
|
- if self.utility_llm_call:
|
|
|
|
|
- try:
|
|
|
|
|
- result = await self.utility_llm_call(
|
|
|
|
|
- messages=[
|
|
|
|
|
- {
|
|
|
|
|
- "role": "system",
|
|
|
|
|
- "content": TASK_NAME_GENERATION_SYSTEM_PROMPT,
|
|
|
|
|
- },
|
|
|
|
|
- {"role": "user", "content": raw_text[:2000]},
|
|
|
|
|
- ],
|
|
|
|
|
- model="gpt-4o-mini", # 使用便宜模型
|
|
|
|
|
- )
|
|
|
|
|
- title = result.get("content", "").strip()
|
|
|
|
|
- if title and len(title) < 100:
|
|
|
|
|
- return title
|
|
|
|
|
- except Exception:
|
|
|
|
|
- pass
|
|
|
|
|
-
|
|
|
|
|
- # Fallback: 截取前 50 字符
|
|
|
|
|
- return raw_text[:50] + ("..." if len(raw_text) > 50 else "")
|
|
|
|
|
|
|
+ return await self._prompt_runtime.generate_task_name(
|
|
|
|
|
+ messages,
|
|
|
|
|
+ utility_llm_call=self.utility_llm_call,
|
|
|
|
|
+ )
|
|
|
|
|
|
|
|
def _format_skills(self, skills: List[Skill]) -> str:
|
|
def _format_skills(self, skills: List[Skill]) -> str:
|
|
|
if not skills:
|
|
if not skills:
|