Explorar el Código

框架:让工具注册器保留预期失败

ToolRegistry 现在区分预期 ToolExecutionError 与未知代码异常。预期拒绝以结构化 warning 返回且不打印堆栈,未知异常只向模型暴露安全错误并在日志保留 traceback。\n\nToolResult.failure 会进入独立控制字段,工具统计也会把结构化拒绝计为失败。补充预期拒绝、异常脱敏和日志级别测试。
SamLee hace 15 horas
padre
commit
2f2efab079
Se han modificado 2 ficheros con 129 adiciones y 21 borrados
  1. 76 21
      agent/agent/tools/registry.py
  2. 53 0
      agent/tests/test_failure_contract.py

+ 76 - 21
agent/agent/tools/registry.py

@@ -16,6 +16,11 @@ import logging
 import time
 from typing import Any, Callable, Dict, List, Optional, Sequence
 
+from agent.failures import (
+	FailureDetail,
+	FailureDisposition,
+	ToolExecutionError,
+)
 from agent.tools.models import ToolCapability
 
 from agent.tools.url_matcher import filter_by_url
@@ -54,6 +59,22 @@ class ToolStats:
 		}
 
 
+def _record_tool_outcome(stats: ToolStats, start_time: float, *, success: bool) -> None:
+	if success:
+		stats.success_count += 1
+	else:
+		stats.failure_count += 1
+	stats.total_duration += time.time() - start_time
+
+
+def _failure_result(failure: FailureDetail) -> Dict[str, Any]:
+	payload = {"failure": failure.to_dict()}
+	return {
+		"text": json.dumps(payload, ensure_ascii=False, indent=2),
+		"_control": {"failure": failure.to_dict()},
+	}
+
+
 class ToolRegistry:
 	"""工具注册表"""
 	_REGISTRATION_FIELDS = (
@@ -361,9 +382,14 @@ class ToolRegistry:
 			JSON 字符串格式的结果
 		"""
 		if name not in self._tools:
-			error_msg = f"Unknown tool: {name}"
-			logger.error(f"[ToolRegistry] {error_msg}")
-			return json.dumps({"error": error_msg}, ensure_ascii=False)
+			failure = FailureDetail(
+				code="UNKNOWN_TOOL",
+				message=f"Unknown tool: {name}",
+				disposition=FailureDisposition.ABORT_RUN,
+				source_tool=name,
+			)
+			logger.error("[ToolRegistry] %s", failure.message)
+			return _failure_result(failure)
 
 		start_time = time.time()
 		stats = self._stats[name]
@@ -442,26 +468,33 @@ class ToolRegistry:
 			else:
 				result = func(**kwargs)
 
-			# 记录成功
-			stats.success_count += 1
-			duration = time.time() - start_time
-			stats.total_duration += duration
-
 			# 返回结果:ToolResult 转为可序列化格式
 			if isinstance(result, str):
+				_record_tool_outcome(stats, start_time, success=True)
 				return result
 
 			# 处理 ToolResult 对象
 			from agent.tools.models import ToolResult
 			if isinstance(result, ToolResult):
+				failure = result.failure
+				if failure is None and result.error:
+					failure = FailureDetail(
+						code="TOOL_REPORTED_ERROR",
+						message=result.error,
+						disposition=FailureDisposition.RETRY_CALL,
+						source_tool=name,
+					)
+					result.failure = failure
 				ret = {"text": result.to_llm_message()}
+				if failure is not None:
+					ret["_control"] = {"failure": failure.to_dict()}
 
 				# Runner 消费的控制字段与业务文本分离,不能靠解析 output 猜测终止。
 				if result.terminate_run:
-					ret["_control"] = {
+					ret.setdefault("_control", {}).update({
 						"terminate_run": True,
 						"result_summary": result.result_summary or result.long_term_memory or result.output,
-					}
+					})
 
 				# 保留images
 				if result.images:
@@ -471,24 +504,46 @@ class ToolRegistry:
 				if result.tool_usage:
 					ret["tool_usage"] = result.tool_usage
 
+				_record_tool_outcome(stats, start_time, success=failure is None)
+
 				# 向后兼容:只有 text 时返回字符串;有控制信息时必须保留 dict。
 				if len(ret) == 1:
 					return ret["text"]
 				return ret
 
+			_record_tool_outcome(stats, start_time, success=True)
 			return json.dumps(result, ensure_ascii=False, indent=2)
 
-		except Exception as e:
-			# 记录失败
-			stats.failure_count += 1
-			duration = time.time() - start_time
-			stats.total_duration += duration
-
-			error_msg = f"Error executing tool '{name}': {str(e)}"
-			logger.error(f"[ToolRegistry] {error_msg}")
-			import traceback
-			logger.error(traceback.format_exc())
-			return json.dumps({"error": error_msg}, ensure_ascii=False)
+		except ToolExecutionError as exc:
+			_record_tool_outcome(stats, start_time, success=False)
+			failure = exc.failure
+			if failure.source_tool is None:
+				failure = FailureDetail(
+					code=failure.code,
+					message=failure.message,
+					disposition=failure.disposition,
+					source_tool=name,
+					details=failure.details,
+				)
+			logger.warning(
+				"[ToolRegistry] expected rejection tool=%s code=%s disposition=%s: %s",
+				name,
+				failure.code,
+				failure.disposition.value,
+				failure.message,
+			)
+			return _failure_result(failure)
+		except Exception:
+			_record_tool_outcome(stats, start_time, success=False)
+			logger.exception("[ToolRegistry] unexpected failure executing tool '%s'", name)
+			return _failure_result(
+				FailureDetail(
+					code="UNEXPECTED_TOOL_ERROR",
+					message="The tool failed unexpectedly",
+					disposition=FailureDisposition.ABORT_RUN,
+					source_tool=name,
+				)
+			)
 
 	def get_stats(self, tool_name: Optional[str] = None) -> Dict[str, Dict[str, Any]]:
 		"""

+ 53 - 0
agent/tests/test_failure_contract.py

@@ -1,10 +1,23 @@
 from __future__ import annotations
 
 import json
+import logging
 
 import pytest
 
 from agent import FailureDetail, FailureDisposition, ToolExecutionError, ToolResult
+from agent.tools.registry import ToolRegistry
+
+
+def _schema(name: str) -> dict:
+    return {
+        "type": "function",
+        "function": {
+            "name": name,
+            "description": name,
+            "parameters": {"type": "object", "properties": {}},
+        },
+    }
 
 
 def test_failure_detail_is_bounded_json_safe_and_round_trips() -> None:
@@ -50,3 +63,43 @@ def test_tool_result_exposes_structured_failure_to_model() -> None:
     assert payload["failure"] == failure.to_dict()
     assert payload["output"] == "context"
     assert ToolExecutionError(failure).failure == failure
+
+
+@pytest.mark.asyncio
+async def test_registry_preserves_expected_failure_without_traceback(caplog) -> None:
+    registry = ToolRegistry()
+
+    async def sample() -> str:
+        raise ToolExecutionError(
+            FailureDetail(
+                code="EXPECTED_REJECTION",
+                message="change the contract",
+                disposition=FailureDisposition.REPLAN_TASK,
+            )
+        )
+
+    registry.register(sample, schema=_schema("sample"))
+    with caplog.at_level(logging.WARNING):
+        result = await registry.execute("sample", {})
+
+    assert result["_control"]["failure"]["code"] == "EXPECTED_REJECTION"
+    assert result["_control"]["failure"]["source_tool"] == "sample"
+    assert not any(record.exc_info for record in caplog.records)
+    assert registry.get_stats("sample")["sample"]["failure_count"] == 1
+
+
+@pytest.mark.asyncio
+async def test_registry_hides_unexpected_error_but_logs_traceback(caplog) -> None:
+    registry = ToolRegistry()
+
+    async def sample() -> str:
+        raise RuntimeError("secret implementation detail")
+
+    registry.register(sample, schema=_schema("sample"))
+    with caplog.at_level(logging.ERROR):
+        result = await registry.execute("sample", {})
+
+    failure = result["_control"]["failure"]
+    assert failure["code"] == "UNEXPECTED_TOOL_ERROR"
+    assert "secret implementation detail" not in result["text"]
+    assert any(record.exc_info for record in caplog.records)