|
|
@@ -0,0 +1,145 @@
|
|
|
+"""Business-neutral failure values shared by tools, runners and orchestration."""
|
|
|
+
|
|
|
+from __future__ import annotations
|
|
|
+
|
|
|
+import hashlib
|
|
|
+import json
|
|
|
+from dataclasses import dataclass, field
|
|
|
+from enum import StrEnum
|
|
|
+from typing import Any, Mapping
|
|
|
+
|
|
|
+_MAX_CODE_LENGTH = 128
|
|
|
+_MAX_MESSAGE_LENGTH = 2_000
|
|
|
+_MAX_SOURCE_LENGTH = 200
|
|
|
+_MAX_DETAILS_BYTES = 16 * 1024
|
|
|
+_DETAIL_PREVIEW_LENGTH = 4_000
|
|
|
+_VOLATILE_DETAIL_KEYS = {
|
|
|
+ "attempt_id",
|
|
|
+ "operation_id",
|
|
|
+ "task_id",
|
|
|
+ "trace_id",
|
|
|
+ "validation_id",
|
|
|
+ "timestamp",
|
|
|
+ "updated_at",
|
|
|
+ "created_at",
|
|
|
+}
|
|
|
+
|
|
|
+
|
|
|
+class FailureDisposition(StrEnum):
|
|
|
+ """What the current role may safely do after a tool failure."""
|
|
|
+
|
|
|
+ RETRY_CALL = "retry_call"
|
|
|
+ REPAIR_ATTEMPT = "repair_attempt"
|
|
|
+ REPLAN_TASK = "replan_task"
|
|
|
+ ABORT_RUN = "abort_run"
|
|
|
+
|
|
|
+
|
|
|
+@dataclass(frozen=True)
|
|
|
+class FailureDetail:
|
|
|
+ """Bounded, JSON-safe failure information suitable for model feedback."""
|
|
|
+
|
|
|
+ code: str
|
|
|
+ message: str
|
|
|
+ disposition: FailureDisposition
|
|
|
+ source_tool: str | None = None
|
|
|
+ details: Mapping[str, Any] = field(default_factory=dict)
|
|
|
+
|
|
|
+ def __post_init__(self) -> None:
|
|
|
+ code = _bounded_text(self.code, _MAX_CODE_LENGTH, "code")
|
|
|
+ message = _bounded_text(self.message, _MAX_MESSAGE_LENGTH, "message")
|
|
|
+ source_tool = (
|
|
|
+ _bounded_text(self.source_tool, _MAX_SOURCE_LENGTH, "source_tool")
|
|
|
+ if self.source_tool is not None
|
|
|
+ else None
|
|
|
+ )
|
|
|
+ try:
|
|
|
+ disposition = FailureDisposition(self.disposition)
|
|
|
+ except (TypeError, ValueError) as exc:
|
|
|
+ raise ValueError("FailureDetail.disposition is invalid") from exc
|
|
|
+ safe_details = _bounded_details(self.details)
|
|
|
+ object.__setattr__(self, "code", code)
|
|
|
+ object.__setattr__(self, "message", message)
|
|
|
+ object.__setattr__(self, "source_tool", source_tool)
|
|
|
+ object.__setattr__(self, "disposition", disposition)
|
|
|
+ object.__setattr__(self, "details", safe_details)
|
|
|
+
|
|
|
+ def to_dict(self) -> dict[str, Any]:
|
|
|
+ return {
|
|
|
+ "code": self.code,
|
|
|
+ "message": self.message,
|
|
|
+ "disposition": self.disposition.value,
|
|
|
+ "source_tool": self.source_tool,
|
|
|
+ "details": dict(self.details),
|
|
|
+ }
|
|
|
+
|
|
|
+ @classmethod
|
|
|
+ def from_dict(cls, value: "FailureDetail | Mapping[str, Any]") -> "FailureDetail":
|
|
|
+ if isinstance(value, cls):
|
|
|
+ return value
|
|
|
+ return cls(
|
|
|
+ code=str(value.get("code", "")),
|
|
|
+ message=str(value.get("message", "")),
|
|
|
+ disposition=FailureDisposition(value.get("disposition", "")),
|
|
|
+ source_tool=(
|
|
|
+ str(value["source_tool"])
|
|
|
+ if value.get("source_tool") is not None
|
|
|
+ else None
|
|
|
+ ),
|
|
|
+ details=(
|
|
|
+ value.get("details", {})
|
|
|
+ if isinstance(value.get("details", {}), Mapping)
|
|
|
+ else {}
|
|
|
+ ),
|
|
|
+ )
|
|
|
+
|
|
|
+ def fingerprint(self) -> str:
|
|
|
+ """Return a stable semantic fingerprint, excluding execution identities."""
|
|
|
+
|
|
|
+ semantic_details = {
|
|
|
+ key: value
|
|
|
+ for key, value in self.details.items()
|
|
|
+ if key not in _VOLATILE_DETAIL_KEYS and not key.endswith("_id")
|
|
|
+ }
|
|
|
+ payload = {
|
|
|
+ "code": self.code,
|
|
|
+ "disposition": self.disposition.value,
|
|
|
+ "source_tool": self.source_tool,
|
|
|
+ "details": semantic_details,
|
|
|
+ }
|
|
|
+ encoded = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
|
+ return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
|
|
|
+
|
|
|
+
|
|
|
+class ToolExecutionError(Exception):
|
|
|
+ """Expected tool rejection carrying a safe, structured failure."""
|
|
|
+
|
|
|
+ def __init__(self, failure: FailureDetail) -> None:
|
|
|
+ self.failure = FailureDetail.from_dict(failure)
|
|
|
+ super().__init__(self.failure.message)
|
|
|
+
|
|
|
+
|
|
|
+def _bounded_text(value: object, limit: int, field_name: str) -> str:
|
|
|
+ if not isinstance(value, str) or not value.strip():
|
|
|
+ raise ValueError(f"FailureDetail.{field_name} must be a non-blank string")
|
|
|
+ text = value.strip()
|
|
|
+ return text if len(text) <= limit else text[: limit - 1] + "…"
|
|
|
+
|
|
|
+
|
|
|
+def _bounded_details(value: Mapping[str, Any]) -> dict[str, Any]:
|
|
|
+ if not isinstance(value, Mapping):
|
|
|
+ raise ValueError("FailureDetail.details must be a mapping")
|
|
|
+ try:
|
|
|
+ normalized = json.loads(json.dumps(dict(value), ensure_ascii=False, sort_keys=True))
|
|
|
+ except (TypeError, ValueError) as exc:
|
|
|
+ raise ValueError("FailureDetail.details must contain JSON values") from exc
|
|
|
+ encoded = json.dumps(normalized, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
|
+ if len(encoded.encode("utf-8")) <= _MAX_DETAILS_BYTES:
|
|
|
+ return normalized
|
|
|
+ return {
|
|
|
+ "truncated": True,
|
|
|
+ "sha256": hashlib.sha256(encoded.encode("utf-8")).hexdigest(),
|
|
|
+ "preview": encoded[:_DETAIL_PREVIEW_LENGTH],
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+__all__ = ["FailureDetail", "FailureDisposition", "ToolExecutionError"]
|