初始化提交

This commit is contained in:
chenxin committed 2026-08-21 16:00:13 +08:00
commit 887c1f449e
963 files changed
+166869

No files matched your search

+840
View File
@@ -0,0 +1,840 @@
"""流式 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",
]