"""Agent Loop 引擎 — Thinking+JSON 推理循环,pluggy 驱动 hook。 算法保真 #11: 完整保留 TRM4 core/loop.py 逻辑: - json_repair 兜底解析 - submit_answer 终止 - 无效工具(ValueError)不计步 - pluggy hook 生命周期(before_step / after_tool / after_step / on_finish) TRM4 → TRM5 有意变更(非简化): - 同步 → 全异步(async/await) - client: Any → llm: LLMProvider(Protocol 类型化) - tool_fn: Callable → ToolDispatcher.dispatch()(Protocol + context) - Step 新增 call_id(从 LLMResponse.call_id 透传) - thinking 从 getattr(msg, "reasoning_content") → response.thinking(adapters 已统一剥离) - token 用量从 response.usage.prompt_tokens → response.prompt_tokens(LLMResponse 扁平化) """ from __future__ import annotations import json from typing import TYPE_CHECKING, Any import pluggy from json_repair import repair_json from loguru import logger from core.agent.protocols import AgentLoopSpec, ToolDispatcher from core.agent.types import LoopResult, Step if TYPE_CHECKING: from core.protocols import LLMProvider from core.types import LLMResponse async def _call_hook(hook: Any, **kwargs: Any) -> list[Any]: """调用 pluggy hook 并 await 异步返回值。 pluggy 本身是同步调度,但 hookimpl 可以是 async def, 此时 hook() 返回 coroutine 列表,需要逐个 await。 参数: hook: pluggy hook caller(如 pm.hook.before_step)。 **kwargs: 传递给 hook 的关键字参数。 返回: 已 resolve 的返回值列表。 """ results = hook(**kwargs) if results is not None: resolved = [] for r in results: if hasattr(r, "__await__"): resolved.append(await r) else: resolved.append(r) return resolved return [] class AgentLoop: """Thinking+JSON 推理循环引擎。 类比 nn.Module: 接收 prompt + 工具调度器,返回 LoopResult。 不感知视频树、QA、数据库等领域概念。 参数: llm: LLMProvider 实例(Protocol 类型化,提供 async chat 方法)。 max_steps: 最大有效步数(每次成功工具调用计一步)。 max_retries: JSON 解析连续失败的最大容忍次数。 """ def __init__( self, llm: LLMProvider, max_steps: int, max_retries: int = 3, ) -> None: self._llm = llm self._max_steps = max_steps self._max_retries = max_retries async def run( self, system_prompt: str, user_prompt: str, tool_dispatcher: ToolDispatcher, plugins: list[object] | None = None, *, session_id: str | None = None, ) -> LoopResult: """执行 Thinking+JSON 推理循环。 参数: system_prompt: 系统提示词。 user_prompt: 用户提示词。 tool_dispatcher: 工具调度器,ToolDispatcher Protocol 实例。 plugins: pluggy 插件列表。 session_id: 会话 ID,透传给 LLMProvider。 返回: LoopResult 实例,包含推理步骤、token 用量、终止原因。 """ pm = self._create_plugin_manager(plugins) messages: list[dict[str, Any]] = [ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt}, ] steps: list[Step] = [] token_usage: dict[str, int] = {"prompt_tokens": 0, "completion_tokens": 0} step_count = 0 retry_count = 0 iteration = 0 while step_count < self._max_steps: await _call_hook(pm.hook.before_step, iteration=iteration, messages=messages) # Phase 1: LLM 调用 try: response = await self._call_llm(messages, token_usage, session_id=session_id) except Exception as e: logger.error("LLM API 调用失败: {}", e) result = LoopResult( steps=steps, steps_used=step_count, token_usage=token_usage, stop_reason="error", ) await _call_hook(pm.hook.on_finish, result=result) return result # Phase 2: 解析响应 parsed = self._parse_response(response) if parsed is None: retry_count += 1 logger.warning("响应解析失败 (retry {}/{})", retry_count, self._max_retries) messages.append({"role": "assistant", "content": response.content}) messages.append( { "role": "user", "content": ( "你的输出不是合法 JSON。请严格输出 JSON 格式:" '{"reflect": {...}, "plan": {...}, ' '"action": {"tool": "...", "args": {...}}}' ), } ) if retry_count >= self._max_retries: result = LoopResult( steps=steps, steps_used=step_count, token_usage=token_usage, stop_reason="parse_error", ) await _call_hook(pm.hook.after_step, iteration=iteration, messages=messages) await _call_hook(pm.hook.on_finish, result=result) return result await _call_hook(pm.hook.after_step, iteration=iteration, messages=messages) iteration += 1 continue thought, reflect, plan, raw_content, action, call_id = parsed retry_count = 0 messages.append({"role": "assistant", "content": raw_content}) # Phase 3: 执行工具 tool_name: str = action["tool"] tool_args: dict[str, Any] = action["args"] context: dict[str, Any] = { "session_id": session_id, "iteration": iteration, } output, is_valid = await self._execute_tool( tool_dispatcher, tool_name, tool_args, context=context ) if not is_valid: messages.append( { "role": "user", "content": f"[工具调用无效: {tool_name}] {output}", } ) await _call_hook(pm.hook.after_step, iteration=iteration, messages=messages) iteration += 1 continue step_count += 1 step = Step( thought=thought, reflect=reflect, plan=plan, tool_call={"tool": tool_name, "args": tool_args}, tool_output=output, raw_content=raw_content, call_id=call_id, ) steps.append(step) # Phase 4: Hook + 反馈 hints = await _call_hook(pm.hook.after_tool, iteration=iteration, step=step) feedback = self._build_feedback(tool_name, output, hints) messages.append(feedback) await _call_hook(pm.hook.after_step, iteration=iteration, messages=messages) # Phase 5: 终止检查 if tool_name == "submit_answer": result = LoopResult( result=tool_args, steps=steps, steps_used=step_count, token_usage=token_usage, stop_reason="finished", ) await _call_hook(pm.hook.on_finish, result=result) return result iteration += 1 # 预算耗尽 result = LoopResult( steps=steps, steps_used=step_count, token_usage=token_usage, stop_reason="budget_exceeded", ) await _call_hook(pm.hook.on_finish, result=result) return result def _create_plugin_manager(self, plugins: list[object] | None) -> pluggy.PluginManager: """创建并注册 plugins 的 PluginManager。 参数: plugins: pluggy 插件列表,可为 None。 返回: 配置好的 PluginManager 实例。 """ pm = pluggy.PluginManager("agent_loop") pm.add_hookspecs(AgentLoopSpec) for plugin in plugins or []: pm.register(plugin) return pm async def _call_llm( self, messages: list[dict[str, Any]], token_usage: dict[str, int], *, session_id: str | None = None, ) -> LLMResponse: """调用 LLMProvider 并累加 token 使用量。 参数: messages: 消息历史。 token_usage: 可变字典,就地累加。 session_id: 会话 ID,透传给 LLMProvider。 返回: LLMResponse 实例。 """ response = await self._llm.chat(messages, session_id=session_id) token_usage["prompt_tokens"] += response.prompt_tokens token_usage["completion_tokens"] += response.completion_tokens return response def _parse_response( self, response: LLMResponse ) -> tuple[str, dict, dict, str, dict, str] | None: """从 LLMResponse 中提取结构化决策数据。 解析流程: content → repair_json → json.loads → 校验 action/tool/args。 参数: response: LLMResponse 实例。 返回: 解析成功返回 (thought, reflect, plan, raw_content, action, call_id); 解析失败返回 None。 """ content = response.content thought = response.thinking if not content.strip(): return None repaired = repair_json(content) try: data = json.loads(repaired) except (json.JSONDecodeError, ValueError): return None if not isinstance(data, dict) or "action" not in data: return None action = data["action"] if not isinstance(action, dict) or "tool" not in action or "args" not in action: return None reflect = data.get("reflect", {}) plan = data.get("plan", {}) return thought, reflect, plan, content, action, response.call_id async def _execute_tool( self, dispatcher: ToolDispatcher, name: str, args: dict[str, Any], *, context: dict[str, Any], ) -> tuple[str, bool]: """执行工具调用。 参数: dispatcher: 工具调度器。 name: 工具名称。 args: 工具参数。 context: 调用上下文(session_id、iteration 等)。 返回: (output, is_valid) — ValueError 时 is_valid=False 且不计步数。 """ try: output = await dispatcher.dispatch(name, args, context=context) return output, True except ValueError as e: return f"工具调用失败: {e}", False def _build_feedback( self, tool_name: str, tool_output: str, hints: list[str | None], ) -> dict[str, Any]: """组装工具结果反馈消息。 参数: tool_name: 工具名称。 tool_output: 工具原始输出。 hints: hook 返回的 hint 列表(含 None)。 返回: user role 消息字典。 """ parts = [f"[工具执行结果: {tool_name}]", tool_output] for hint in hints: if hint is not None: parts.append(hint) return {"role": "user", "content": "\n".join(parts)}