Files
2026-08-21 16:00:13 +08:00

841 lines
36 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""流式 Agent 循环:stream → 累积 tool_calls → execute → 回填 ToolMessage → 再 stream。
阶段2b 步骤A:把 Agent 工具调用闭环从非流式 ``router.chat`` 改成流式 ``router.stream``,
并在主循环中暴露两个可选 async 回调(``on_chunk`` / ``on_tool_event``)供上层(步骤B
send_message)接入 SSE 增量冲刷与工具事件。本模块不感知 SSE / 前端,回调签名仅约定:
- ``on_chunk(chunk: ChatChunk)``:每个流式 chunk 透传(含 delta_content / delta_reasoning
/ tool_call_chunks / usage 等),上层可据此写 ``message.delta``。
- ``on_tool_event(event_type: str, payload: dict)``:工具生命周期事件:
- ``"tool.started"``:执行前
- ``"tool.completed"``:执行后,含 status(success / error / denied)
- ``"tool.requires_confirmation"``(阶段3 新增):高风险工具抛 RequiresConfirmation 时
发出,带 confirmation_id / risk_level / args,供前端展示确认卡片、approve/deny 端点
据此恢复循环。
阶段3 步骤1:工具确认挂起/恢复机制。
- 主循环遇到 ``RequiresConfirmation`` 时,通过 ``confirmation_registry.register(cid)`` 取
一个 ``Future[bool]`` 并 ``await``;外部 approve/deny 端点(步骤2)调
``registry.resolve(cid, approved)`` 触发恢复。
- 批准:以 ``confirmed=True`` 重试 execute;拒绝:回填"用户拒绝"ToolMessage,循环继续。
- 超时:``confirmation_timeout``(默认 300s)未裁决按拒绝处理;该超时受外层
``run_timeout``(send_message 的 ``asyncio.timeout``)约束——确认必须在 run 超时内完成。
- ``confirmation_registry`` 为 None 时降级:回填"需确认但未启用"消息,循环不卡(向下兼容)。
设计要点:
- 流式 ``router.stream(...)`` 返回 ``AsyncIterator[ChatChunk]``;流结束(async for 正常
退出)即本轮模型输出完毕,``ToolCallAccumulator.drain()`` 给出完整 tool_calls——不依赖
``finish_reason`` 判断。
- 工具执行经 ``ToolRuntime.execute``——司命评估与事件落库由其内部完成,本层只负责调用与
错误兜底。
- 工具失败**不中断循环**:异常被捕获、错误信息以 ToolMessage content 回填,让模型自行决策。
RequiresConfirmation 单独处理(不走错误兜底分支)。
- usage 累计每轮 prompt/completion/total_tokens。
- ``on_chunk`` / ``on_tool_event`` 默认 None;不传时本循环与纯后端逻辑等价。
"""
from __future__ import annotations
import json
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from typing import Any
from app.ai_provider import ProviderManagerFactory, TokenUsage
from app.ai_provider.llm import ToolCallAccumulator
from app.ai_provider.types.chat import ChatChunk
from app.ai_provider.types.messages import AIMessage, BaseMessage, ToolMessage
from app.application.agent.confirmation import ConfirmationRegistry
from app.application.agent.extensions import (
AgentContext,
AgentEvent,
AgentRunContext,
CancellationToken,
ExtensionRegistry,
ToolCallContext,
TurnSnapshot,
)
from app.core.logging import logger
from app.models.safety import PendingConfirmationRecord
from app.modules.safety.confirmation import new_confirmation_id
from app.modules.safety.exceptions import RequiresConfirmation
from app.modules.tool_runtime.definition import ToolResult, ToolRuntimeLike
# 默认最大循环次数:覆盖常见多步工具调用,超限则截断返回,避免无限循环。
DEFAULT_MAX_ITERATIONS = 8
# 默认确认超时:高风险工具挂起等待用户裁决的最长时间(秒);超时按拒绝处理。
DEFAULT_CONFIRMATION_TIMEOUT = 300.0
# 回调类型别名:给上层(步骤B send_message)类型检查用
OnChunkCallback = Callable[[ChatChunk], Awaitable[None]]
OnToolEventCallback = Callable[[str, dict[str, Any]], Awaitable[None]]
@dataclass
class AgentTurnResult:
"""单轮 Agent 循环的产出。"""
content: str # 最终回复正文(最后一次模型输出)
reasoning: str # 最终回复的思考内容(如有)
tool_history: list[dict[str, Any]] = field(
default_factory=list
) # [{name, args, result, status, tool_call_id?}]
iterations: int = 0 # 实际循环次数
truncated: bool = False # 是否因达上限截断
usage: TokenUsage = field(default_factory=TokenUsage) # 累计 token 用量
def _sum_usage(acc: TokenUsage, delta: TokenUsage) -> TokenUsage:
return acc + delta
def _parse_tool_args(args_str: str) -> dict[str, Any]:
"""把累积到的 args JSON 字符串解析成 dict;解析失败时回退 ``{"_raw": ...}``。"""
if not args_str:
return {}
try:
parsed = json.loads(args_str)
except json.JSONDecodeError:
return {"_raw": args_str}
if isinstance(parsed, dict):
return parsed
# 非对象 JSON(数组/字符串/数字)——包成 _raw 让模型自己理解
return {"_raw": args_str}
def _ai_message_from_stream(
content: str,
reasoning: str,
tool_calls: list[dict[str, Any]],
raw_content_blocks: list[dict[str, Any]] | None = None,
) -> AIMessage:
"""把流式累积的 content/reasoning/tool_calls 还原成 AIMessage 以便追加到 messages 历史。
``tool_calls`` 元素结构为 ``{"index", "id"?, "name"?, "args": str}``(由
``ToolCallAccumulator.drain`` 产出,``args`` 为完整 JSON 字符串)。AIMessage 期望
``{"name": ..., "args": dict, "id": ...}``,故需反序列化 args 并补全缺失 id。
"""
lc_tool_calls: list[dict[str, Any]] = []
for entry in tool_calls:
name = entry.get("name")
if not name:
# 没有 name 的 tool_call 无法执行,跳过(保留在累积器视图但不进历史)
continue
tc_id = entry.get("id") or f"call_{name}"
lc_tool_calls.append(
{
"name": name,
"args": _parse_tool_args(entry.get("args", "")),
"id": tc_id,
}
)
return AIMessage(
content=content,
tool_calls=lc_tool_calls,
reasoning=reasoning,
raw_content_blocks=raw_content_blocks,
)
def _truncate(text: str, limit: int = 4000) -> str:
"""截断超长文本(沙箱 stdout/stderr 可能很大),避免喂回模型时爆 token。
措辞明确这是「回显截断」而非数据损坏,并告知取后续内容的方法:否则模型读到大文件
被 read_file 截断的结果,会反复换方法重读,陷入循环直到 ``max_iterations`` 截断。
阈值 4000 兼顾 token 成本与单次可读量;大文件配合 read_file 的 offset/limit 分段读取。
"""
if len(text) <= limit:
return text
return (
f"{text[:limit]}\n…[输出过长,仅回显前 {limit} 字符;原始内容完整。"
f"如需后续内容:read_file 用 offset/limit 分段,或 shell 用 sed -n / tail / head 限定输出]"
)
async def _publish_message_lifecycle(
registry: ExtensionRegistry,
run: AgentRunContext,
message: BaseMessage,
) -> None:
"""发布一条完整消息的 start/end 生命周期。"""
if not registry.enabled:
return
payload = {"role": message.role, "message": message.to_dict()}
await registry.publish(AgentEvent("message.started", payload), run)
await registry.publish(AgentEvent("message.ended", payload), run)
def _tool_result_text(result: ToolResult) -> str:
"""把 ToolResult 转成回填给模型的紧凑文本。
显式标注 ``status=成功/失败``:否则成功路径(如 write_file 落盘后 stdout/stderr 全空)
只剩孤零零的 ``exit_code=0``,模型缺乏「已成功」的明确语义,倾向再调 read_file 验证,
叠加回显截断极易陷入反复重试。
"""
status = "成功" if result.ok else "失败"
parts = [f"status={status} exit_code={result.exit_code}"]
if result.stdout:
parts.append(f"stdout:\n{_truncate(result.stdout)}")
if result.stderr:
parts.append(f"stderr:\n{_truncate(result.stderr)}")
if result.timeout:
parts.append("timeout=true")
if result.truncated:
parts.append("truncated=true(仅截断回显给模型的输出,不影响已落盘等实际副作用)")
return "\n".join(parts)
# 控制流哨兵异常:只在本模块内传递,不泄漏到 run_agent_turn 调用方。
class _ToolDenied(Exception):
"""用户拒绝 / 确认超时。``message`` 为回填文本,``reason`` 为内部归因(denied/timeout)。"""
def __init__(self, message: str, *, reason: str = "denied") -> None:
super().__init__(message)
self.message = message
self.reason = reason
class _ToolSkipped(Exception):
"""confirmation_registry 未启用时的降级:回填"需确认但未启用"消息。"""
def __init__(self, message: str) -> None:
super().__init__(message)
self.message = message
async def _execute_tool_with_confirmation(
*,
tool_runtime: ToolRuntimeLike | None,
name: str,
tool_input: dict[str, Any],
args_str: str,
tool_call_id: str,
run_id: str | None,
character_id: str | None,
trace_id: str | None,
base_confirmed: bool,
confirmation_registry: ConfirmationRegistry | None,
confirmation_timeout: float,
on_tool_event: OnToolEventCallback | None,
cancellation: CancellationToken | None = None,
on_timeout: Callable[[str], Awaitable[None]] | None = None,
) -> ToolResult:
"""执行单次工具调用,处理 RequiresConfirmation 挂起/恢复。
- 正常返回 ToolResult;任意终态(拒绝/超时/降级)以本模块哨兵异常向调用方信号。
- 批准路径以 ``confirmed=True`` 重试 execute(绕过司命 ask),失败仍正常返回 ToolResult
(由调用方按 ok=False 回填 error 文本)。
- ``on_timeout(confirmation_id)``:确认超时时回调,供调用方标记超时。
"""
# base_confirmed=True 表示调用方已保证确认(如 API 已先确认),直接执行
use_confirmed = base_confirmed
if tool_runtime is None:
raise _ToolSkipped("工具运行时未启用,已跳过工具调用。")
while True:
try:
return await tool_runtime.execute(
name,
tool_input,
run_id=run_id,
character_id=character_id,
trace_id=trace_id,
confirmed=use_confirmed,
context={"cancellation": cancellation} if cancellation is not None else None,
)
except RequiresConfirmation as exc:
if use_confirmed:
raise _ToolSkipped(f"工具 {name} 在已确认状态下仍要求确认,已跳过。") from exc
# ---- registry 未启用:降级 ----
if confirmation_registry is None:
raise _ToolSkipped(
f"工具 {name} 需要用户确认(风险等级 {exc.risk_level or 'L2'}),"
"但当前未启用确认流程,已跳过。"
) from exc
# ---- registry 已启用:登记 + 持久化待确认 + 发事件 + await ----
confirmation_id = new_confirmation_id()
# 先持久化待确认记录(多 worker 共享确认状态),再发事件。
# 顺序保证:resolve/deny 回调(on_tool_event 触发的 create_task)看到记录时,
# DB 里已有该行,不会空转超时。
if run_id:
from app.db.session import AsyncSessionLocal
async with AsyncSessionLocal() as session:
record = PendingConfirmationRecord(
id=confirmation_id,
tool=name,
input_json=json.dumps(
{
"name": name,
"args": tool_input,
"tool_call_id": tool_call_id,
"args_str": args_str,
}
),
risk_level=exc.risk_level or "L2",
reason=exc.message,
run_id=run_id,
character_id=character_id,
status="pending",
)
session.add(record)
await session.commit()
if on_tool_event is not None:
await on_tool_event(
"tool.requires_confirmation",
{
"confirmation_id": confirmation_id,
"run_id": run_id,
"tool_name": name,
"tool_call_id": tool_call_id,
"risk_level": exc.risk_level,
"reason": exc.message,
"args": args_str,
},
)
# 使用混合确认注册表(同进程 Event + 重启后 polling)。无 run_id 的
# 独立调用仍可等待到超时,避免把“未裁决”误报成“确认机制未启用”。
confirmation_run_id = run_id or confirmation_id
try:
approved = await confirmation_registry.wait(
confirmation_run_id,
confirmation_id=confirmation_id,
timeout=confirmation_timeout,
)
except TimeoutError:
if on_timeout is not None:
await on_timeout(confirmation_id)
raise _ToolDenied(
f"工具 {name} 的确认超时(等待 {confirmation_timeout:.0f}s 未裁决),已按拒绝处理。",
reason="timeout",
) from None
if not approved:
raise _ToolDenied(f"用户拒绝了工具 {name} 的执行。") from exc
# 批准:以 confirmed=True 重试 execute(循环回到顶部 try)
logger.info(
"agent_tool_confirmed",
tool=name,
confirmation_id=confirmation_id,
run_id=run_id,
)
use_confirmed = True
if on_tool_event is not None:
await on_tool_event(
"tool.started",
{
"tool_name": name,
"tool_call_id": tool_call_id,
"args": args_str,
"confirmed": True,
},
)
async def run_agent_turn(
*,
messages: list[BaseMessage],
tools: list[dict[str, Any]],
tool_runtime: ToolRuntimeLike | None,
model_hint: str | None = None,
thinking_enabled: bool | None = None,
reasoning_effort: str | None = None,
max_iterations: int = DEFAULT_MAX_ITERATIONS,
# 可选上下文:透传给 ToolRuntime.execute 做事件归属与工作目录隔离
run_id: str | None = None,
character_id: str | None = None,
trace_id: str | None = None,
# 可选:高风险工具是否已由上层确认(透传给 ToolRuntime.execute)
confirmed: bool = False,
# 可选:每轮开始时回调(供外层重置 per-turn 状态如 reasoning_finalized)
on_turn: Callable[[], Awaitable[None]] | None = None,
# 可选回调:把流式 chunk 透传给上层做 SSE 增量冲刷(默认 None 表示不回调)
on_chunk: OnChunkCallback | None = None,
# 可选回调:工具生命周期事件("tool.started" / "tool.completed" /
# "tool.requires_confirmation")
on_tool_event: OnToolEventCallback | None = None,
# 可选:确认 Future 注册表(阶段3 步骤1)。缺省 None 时遇 RequiresConfirmation 降级。
confirmation_registry: ConfirmationRegistry | None = None,
# 可选:单次确认挂起超时(秒),默认 300s;超时按拒绝处理。
confirmation_timeout: float = DEFAULT_CONFIRMATION_TIMEOUT,
# 可选:确认超时回调(confirmation_id → None),供调用方标记 DB 超时状态。
on_confirm_timeout: Callable[[str], Awaitable[None]] | None = None,
# 受约束扩展 Hook 与本次运行上下文。
extensions: ExtensionRegistry | None = None,
run_context: AgentRunContext | None = None,
# Stateful Agent 的 steering 队列;每轮工具批次完成后消费。
get_steering_messages: Callable[[], list[BaseMessage]] | None = None,
) -> AgentTurnResult:
"""执行一轮带工具调用的 Agent 循环(流式版)。
循环逻辑见模块 docstring;返回值始终是 ``AgentTurnResult``,工具失败不抛异常。
``max_iterations`` 为硬上限:达到上限且模型仍返回 tool_calls 时返回 truncated=True。
``on_chunk`` / ``on_tool_event`` 缺省 None 时与纯后端逻辑等价。
``on_confirm_timeout(confirmation_id)``:确认超时时回调,用于标记 DB 记录。
确认挂起/恢复(阶段3 步骤1):``confirmation_registry`` 缺省 None 时,遇
``RequiresConfirmation`` 走降级路径(回填"需确认但未启用"消息,循环继续);否则按
register → 发 ``tool.requires_confirmation`` 事件 → ``await Future``(带超时)→
批准重试 / 拒绝回填 的流程处理。``confirmation_timeout`` 须小于上层 run_timeout,否则
run 先超时。
"""
if max_iterations < 1:
raise ValueError("max_iterations 必须 >= 1")
manager = ProviderManagerFactory.create()
extension_registry = extensions or ExtensionRegistry()
extension_context = run_context or AgentRunContext(
run_id=run_id,
character_id=character_id,
trace_id=trace_id,
)
usage = TokenUsage()
tool_history: list[dict[str, Any]] = []
iterations = 0
# 最后一轮累积值(用于 truncated 分支返回 content/reasoning)
last_content = ""
last_reasoning = ""
while iterations < max_iterations:
iterations += 1
if on_turn is not None:
await on_turn()
if extension_registry.enabled:
await extension_registry.publish(
AgentEvent("agent.turn.started", {"iteration": iterations}),
extension_context,
)
# ---- 本轮流式消费 ----
acc = ToolCallAccumulator()
content_buf = ""
reasoning_buf = ""
finish_reason: str | None = None
raw_blocks: dict[int, dict[str, Any]] = {}
params: dict[str, object] = {}
if thinking_enabled is not None:
params["thinking_enabled"] = thinking_enabled
if reasoning_effort is not None:
params["reasoning_effort"] = reasoning_effort
extension_context.cancellation.raise_if_cancelled()
request_context = AgentContext(tuple(messages), tuple(tools), model_hint)
if extension_registry.enabled:
request_context = await extension_registry.transform_context(
request_context,
extension_context,
)
if extension_registry.enabled:
await extension_registry.publish(
AgentEvent("message.started", {"role": "assistant", "iteration": iterations}),
extension_context,
)
stream = manager.chat_stream(
list(request_context.messages),
model_hint=request_context.model_hint,
params=params,
tools=list(request_context.tools) or None,
)
async for chunk in stream:
if on_chunk is not None:
await on_chunk(chunk)
if extension_registry.enabled and (chunk.delta_content or chunk.delta_reasoning):
await extension_registry.publish(
AgentEvent(
"message.updated",
{
"role": "assistant",
"iteration": iterations,
"delta": chunk.delta_content,
"reasoning_delta": chunk.delta_reasoning,
},
),
extension_context,
)
if chunk.delta_content:
content_buf += chunk.delta_content
if chunk.delta_reasoning:
reasoning_buf += chunk.delta_reasoning
if chunk.finish_reason is not None:
finish_reason = chunk.finish_reason
if chunk.content_block_start is not None and chunk.content_block_index is not None:
raw_blocks[chunk.content_block_index] = dict(chunk.content_block_start)
if chunk.content_block_delta is not None and chunk.content_block_index is not None:
block = raw_blocks.get(chunk.content_block_index)
if block is not None:
delta = chunk.content_block_delta
delta_type = delta.get("type")
if delta_type == "text_delta":
block["text"] = f"{block.get('text', '')}{delta.get('text', '')}"
elif delta_type == "thinking_delta":
block["thinking"] = (
f"{block.get('thinking', '')}{delta.get('thinking', '')}"
)
elif delta_type == "signature_delta":
block["signature"] = (
f"{block.get('signature', '')}{delta.get('signature', '')}"
)
elif delta_type == "input_json_delta":
block["_partial_json"] = (
f"{block.get('_partial_json', '')}{delta.get('partial_json', '')}"
)
if chunk.usage is not None:
usage = _sum_usage(usage, chunk.usage)
acc.add_chunk(chunk)
# 流结束即本轮模型输出完毕
tool_calls = acc.drain()
last_content = content_buf
last_reasoning = reasoning_buf
raw_content_blocks: list[dict[str, Any]] = []
for index in sorted(raw_blocks):
block = raw_blocks[index]
partial_json = block.pop("_partial_json", None)
if partial_json is not None:
block["input"] = _parse_tool_args(partial_json)
raw_content_blocks.append(block)
# 把模型输出(含 thinking signature / tool_calls)原样追加到历史。
ai_message = _ai_message_from_stream(
content_buf,
reasoning_buf,
tool_calls,
raw_content_blocks or None,
)
messages.append(ai_message)
if extension_registry.enabled:
await extension_registry.publish(
AgentEvent(
"message.ended",
{
"role": "assistant",
"iteration": iterations,
"message": ai_message.to_dict(),
"finish_reason": finish_reason,
},
),
extension_context,
)
# 无工具调用:先消费 steering,再决定是否结束。
if not any(tc.get("name") for tc in tool_calls):
steering = get_steering_messages() if get_steering_messages is not None else []
turn = TurnSnapshot(
iteration=iterations,
content=content_buf,
reasoning=reasoning_buf,
tool_calls=(),
tool_results=tuple(tool_history),
usage=usage,
)
if extension_registry.enabled:
await extension_registry.publish(
AgentEvent("agent.turn.ended", {"iteration": iterations}),
extension_context,
)
# TurnPolicy 在此分支不参与终止决策:模型无工具调用时已由 steering /
# finish_reason / 默认 return 收尾,强行接入会覆盖 pause_turn / refusal
# 既有处理;其真实终止作用在下方工具分支(should_stop or terminate_after_tools)。
await extension_registry.should_stop(turn, extension_context)
if steering:
messages.extend(steering)
continue
if finish_reason == "pause_turn":
continue
if finish_reason == "refusal":
raise RuntimeError("模型拒绝了当前请求。")
return AgentTurnResult(
content=content_buf,
reasoning=reasoning_buf,
tool_history=tool_history,
iterations=iterations,
truncated=finish_reason == "length",
usage=usage,
)
# 逐个执行 tool_call 并把结果以 ToolMessage 回填。工具可能有副作用或需要确认,
# 在 ToolSpec 未声明并行安全前保持确定性串行执行。
turn_history_start = len(tool_history)
terminate_after_tools = False
for call_index, entry in enumerate(tool_calls):
name = entry.get("name")
if not name:
continue
args_str = entry.get("args", "")
tool_input = _parse_tool_args(args_str)
tool_call_id = entry.get("id") or f"call_{iterations}_{call_index}_{name}"
hook_call = ToolCallContext(
tool_call_id=tool_call_id,
name=name,
input=tool_input,
messages=tuple(messages),
)
if extension_registry.enabled:
decision = await extension_registry.before_tool_call(
hook_call,
extension_context,
)
if not decision.allowed:
denied_text = decision.reason or f"扩展阻止了工具 {name} 的执行。"
tool_history.append(
{
"name": name,
"args": tool_input,
"result": denied_text,
"status": "denied",
"tool_call_id": tool_call_id,
}
)
tool_message = ToolMessage(content=denied_text, tool_call_id=tool_call_id)
messages.append(tool_message)
await _publish_message_lifecycle(
extension_registry, extension_context, tool_message
)
if on_tool_event is not None:
await on_tool_event(
"tool.completed",
{
"tool_name": name,
"tool_call_id": tool_call_id,
"status": "denied",
"result": denied_text[:500],
},
)
continue
if decision.input is not None:
tool_input = decision.input
args_str = json.dumps(tool_input, ensure_ascii=False)
hook_call = ToolCallContext(
tool_call_id=tool_call_id,
name=name,
input=tool_input,
messages=tuple(messages),
)
# 执行前事件
if on_tool_event is not None:
await on_tool_event(
"tool.started",
{
"tool_name": name,
"tool_call_id": tool_call_id,
"args": args_str,
},
)
try:
tool_result = await _execute_tool_with_confirmation(
tool_runtime=tool_runtime,
name=name,
tool_input=tool_input,
args_str=args_str,
tool_call_id=tool_call_id,
run_id=run_id,
character_id=character_id,
trace_id=trace_id,
base_confirmed=confirmed,
confirmation_registry=confirmation_registry,
confirmation_timeout=confirmation_timeout,
on_tool_event=on_tool_event,
cancellation=extension_context.cancellation,
on_timeout=on_confirm_timeout,
)
except _ToolDenied as denied:
# 用户拒绝或确认超时:回填拒绝消息,循环继续让模型自决策
logger.info(
"agent_tool_denied",
tool=name,
reason=denied.reason,
run_id=run_id,
)
denied_text = denied.message
tool_history.append(
{
"name": name,
"args": tool_input,
"result": denied_text,
"status": "denied",
"tool_call_id": tool_call_id,
}
)
tool_message = ToolMessage(content=denied_text, tool_call_id=tool_call_id)
messages.append(tool_message)
await _publish_message_lifecycle(
extension_registry, extension_context, tool_message
)
if on_tool_event is not None:
await on_tool_event(
"tool.completed",
{
"tool_name": name,
"tool_call_id": tool_call_id,
"status": "denied",
"result": denied_text[:500],
},
)
continue
except _ToolSkipped as skipped:
# registry 未启用:回填"需确认但未启用",循环继续
skipped_text = skipped.message
tool_history.append(
{
"name": name,
"args": tool_input,
"result": skipped_text,
"status": "skipped",
"tool_call_id": tool_call_id,
}
)
tool_message = ToolMessage(content=skipped_text, tool_call_id=tool_call_id)
messages.append(tool_message)
await _publish_message_lifecycle(
extension_registry, extension_context, tool_message
)
if on_tool_event is not None:
await on_tool_event(
"tool.completed",
{
"tool_name": name,
"tool_call_id": tool_call_id,
"status": "skipped",
"result": skipped_text[:500],
},
)
continue
except Exception as exc: # noqa: BLE001 - 任意工具错误都回填、不中断循环
# 司命 deny / 沙箱错误 / 未知工具等:错误信息回填,循环继续
logger.warning(
"agent_tool_failed",
tool=name,
error=str(exc),
run_id=run_id,
)
error_text = f"工具执行失败:{type(exc).__name__}: {_truncate(str(exc))}"
tool_history.append(
{
"name": name,
"args": tool_input,
"result": error_text,
"status": "error",
"tool_call_id": tool_call_id,
}
)
tool_message = ToolMessage(content=error_text, tool_call_id=tool_call_id)
messages.append(tool_message)
await _publish_message_lifecycle(
extension_registry, extension_context, tool_message
)
if on_tool_event is not None:
await on_tool_event(
"tool.completed",
{
"tool_name": name,
"tool_call_id": tool_call_id,
"status": "error",
"result": error_text[:500],
},
)
continue
# 成功路径:扩展可变换结果或提示终止;安全裁定已在 ToolRuntime 内完成。
if extension_registry.enabled:
after = await extension_registry.after_tool_call(
hook_call,
tool_result,
extension_context,
)
tool_result = after.result or tool_result
terminate_after_tools = terminate_after_tools or after.terminate
outcome = _tool_result_text(tool_result)
status = "success" if tool_result.ok else "error"
if not tool_result.ok:
# 沙箱执行返回非 ok(exit_code != 0 等)——不抛异常,回填让模型自决策
logger.info(
"agent_tool_returned_error",
tool=name,
exit_code=tool_result.exit_code,
stderr=tool_result.stderr[:500] if tool_result.stderr else "",
run_id=run_id,
)
tool_history.append(
{
"name": name,
"args": tool_input,
"result": outcome,
"status": status,
"tool_call_id": tool_call_id,
}
)
tool_message = ToolMessage(content=outcome, tool_call_id=tool_call_id)
messages.append(tool_message)
await _publish_message_lifecycle(extension_registry, extension_context, tool_message)
if on_tool_event is not None:
await on_tool_event(
"tool.completed",
{
"tool_name": name,
"tool_call_id": tool_call_id,
"status": status,
"result": outcome[:500],
},
)
steering = get_steering_messages() if get_steering_messages is not None else []
if steering:
messages.extend(steering)
turn = TurnSnapshot(
iteration=iterations,
content=content_buf,
reasoning=reasoning_buf,
tool_calls=tuple(tool_calls),
tool_results=tuple(tool_history[turn_history_start:]),
usage=usage,
)
should_stop = terminate_after_tools
if extension_registry.enabled:
await extension_registry.publish(
AgentEvent("agent.turn.ended", {"iteration": iterations}),
extension_context,
)
should_stop = should_stop or await extension_registry.should_stop(
turn,
extension_context,
)
if should_stop and not steering:
return AgentTurnResult(
content=content_buf,
reasoning=reasoning_buf,
tool_history=tool_history,
iterations=iterations,
truncated=False,
usage=usage,
)
# 达上限仍带 tool_calls:截断返回,content 用最后一次模型输出或说明已截断
content = last_content or f"Agent 循环达到最大次数 {max_iterations},已截断。"
return AgentTurnResult(
content=content,
reasoning=last_reasoning,
tool_history=tool_history,
iterations=iterations,
truncated=True,
usage=usage,
)
__all__ = [
"DEFAULT_CONFIRMATION_TIMEOUT",
"DEFAULT_MAX_ITERATIONS",
"AgentTurnResult",
"OnChunkCallback",
"OnToolEventCallback",
"run_agent_turn",
]