feat(core/agent): 实现 AgentLoop 推理循环引擎

保真 TRM4 算法 #11: json_repair 兜底、submit_answer 终止、
pluggy hook 生命周期、无效工具不计步。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-06 22:43:22 -04:00
parent c2ff2855b7
commit 9ca9035190
2 changed files with 588 additions and 0 deletions
+347
View File
@@ -0,0 +1,347 @@
"""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: LLMProviderProtocol 类型化)
- tool_fn: Callable → ToolDispatcher.dispatch()Protocol + context
- Step 新增 call_id(从 LLMResponse.call_id 透传)
- thinking 从 getattr(msg, "reasoning_content") → response.thinkingadapters 已统一剥离)
- token 用量从 response.usage.prompt_tokens → response.prompt_tokensLLMResponse 扁平化)
"""
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 = 15,
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)}