841 lines
36 KiB
Python
841 lines
36 KiB
Python
"""流式 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",
|
||
]
|