"""流式 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", ]