mirror of
https://github.com/eigent-ai/eigent.git
synced 2026-08-26 17:12:03 +00:00
109 lines
3.7 KiB
Python
109 lines
3.7 KiB
Python
"""Typed timeout and external-side-effect recovery policies."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import asdict, dataclass, field
|
|
from enum import StrEnum
|
|
from typing import Any
|
|
|
|
|
|
class TimeoutScope(StrEnum):
|
|
TRANSPORT_IDLE = "transport_idle"
|
|
RUNTIME_LIVENESS = "runtime_liveness"
|
|
ACTIVITY = "activity_timeout"
|
|
TOOL = "tool_timeout"
|
|
RUN_DEADLINE = "run_deadline"
|
|
APPROVAL_EXPIRY = "approval_expiry"
|
|
REMOTE_COMMAND_TTL = "remote_command_ttl"
|
|
CLOUD_SYNC = "cloud_sync_timeout"
|
|
|
|
|
|
class ToolSafetyClass(StrEnum):
|
|
SAFE_READ = "safe_read"
|
|
IDEMPOTENT_WRITE = "idempotent_write"
|
|
UNSAFE_WRITE = "unsafe_write"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class RunTimeoutPolicy:
|
|
policy_version: str = "v1"
|
|
run_deadline_at: float | None = None
|
|
active_execution_budget_ms: int | None = None
|
|
default_activity_timeout_ms: int = 300_000
|
|
activity_timeout_by_type: dict[str, int] = field(default_factory=dict)
|
|
tool_timeout_by_class: dict[str, int] = field(default_factory=dict)
|
|
approval_expires_at: float | None = None
|
|
approval_expiry_action: str = "keep_pending"
|
|
|
|
def __post_init__(self) -> None:
|
|
if not self.policy_version.strip():
|
|
raise ValueError("timeout policy version must not be empty")
|
|
positive = {
|
|
"active_execution_budget_ms": self.active_execution_budget_ms,
|
|
"default_activity_timeout_ms": self.default_activity_timeout_ms,
|
|
**{
|
|
f"activity_timeout_by_type.{key}": value
|
|
for key, value in self.activity_timeout_by_type.items()
|
|
},
|
|
**{
|
|
f"tool_timeout_by_class.{key}": value
|
|
for key, value in self.tool_timeout_by_class.items()
|
|
},
|
|
}
|
|
for name, value in positive.items():
|
|
if value is not None and value <= 0:
|
|
raise ValueError(f"{name} must be positive")
|
|
if self.approval_expiry_action not in {"keep_pending", "reject"}:
|
|
raise ValueError(
|
|
"approval expiry action must be keep_pending or reject"
|
|
)
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
return asdict(self)
|
|
|
|
@classmethod
|
|
def from_dict(cls, value: dict[str, Any] | None) -> RunTimeoutPolicy:
|
|
return cls(**(value or {}))
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class TimeoutOutcome:
|
|
scope: TimeoutScope
|
|
policy_version: str
|
|
reason: str
|
|
started_at: float
|
|
ended_at: float
|
|
run_id: str
|
|
attempt_id: str | None = None
|
|
activity_id: str | None = None
|
|
tool_call_id: str | None = None
|
|
approval_id: str | None = None
|
|
|
|
def __post_init__(self) -> None:
|
|
if self.ended_at < self.started_at:
|
|
raise ValueError("timeout outcome cannot end before it starts")
|
|
if not self.reason.strip():
|
|
raise ValueError("timeout outcome reason must not be empty")
|
|
if self.scope is TimeoutScope.TOOL and not self.tool_call_id:
|
|
raise ValueError("tool timeout requires tool_call_id")
|
|
if self.scope is TimeoutScope.ACTIVITY and not self.activity_id:
|
|
raise ValueError("activity timeout requires activity_id")
|
|
if self.scope is TimeoutScope.APPROVAL_EXPIRY and not self.approval_id:
|
|
raise ValueError("approval timeout requires approval_id")
|
|
|
|
def to_payload(self) -> dict[str, Any]:
|
|
payload = asdict(self)
|
|
payload["scope"] = self.scope.value
|
|
return payload
|
|
|
|
|
|
def automatic_tool_replay_allowed(
|
|
safety_class: ToolSafetyClass,
|
|
*,
|
|
idempotency_key: str | None,
|
|
) -> bool:
|
|
if safety_class is ToolSafetyClass.SAFE_READ:
|
|
return True
|
|
if safety_class is ToolSafetyClass.IDEMPOTENT_WRITE:
|
|
return bool(idempotency_key)
|
|
return False
|