mmo-runtime 0.1.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,55 @@
1
+ """mmo-runtime: Runtime library for MMO gRPC tools."""
2
+
3
+ from .client import ToolClient, consume_stream, ensure_running
4
+ from .config import BaseServerConfig, load_config
5
+ from .context import CancelToken, ProgressReporter, RunContext
6
+ from .errors import (
7
+ Cancelled,
8
+ DeadlineExceeded,
9
+ Internal,
10
+ InvalidRequest,
11
+ ProviderError,
12
+ ResourceExhausted,
13
+ ToolError,
14
+ to_grpc_status,
15
+ wrap_unknown,
16
+ )
17
+ from .events import ErrorEnvelope, ProgressEvent, ResultEnvelope
18
+ from .layout import RunLayout
19
+ from .logging import setup_logging
20
+ from .manifest import Manifest
21
+ from .server import serve
22
+ from .worker import run_in_worker
23
+
24
+ try:
25
+ from ._version import __version__
26
+ except ImportError: # pragma: no cover
27
+ __version__ = "0.0.0+unknown"
28
+
29
+ __all__ = [
30
+ "BaseServerConfig",
31
+ "CancelToken",
32
+ "Cancelled",
33
+ "DeadlineExceeded",
34
+ "ErrorEnvelope",
35
+ "Internal",
36
+ "InvalidRequest",
37
+ "Manifest",
38
+ "ProgressEvent",
39
+ "ProgressReporter",
40
+ "ProviderError",
41
+ "ResourceExhausted",
42
+ "ResultEnvelope",
43
+ "RunContext",
44
+ "RunLayout",
45
+ "ToolClient",
46
+ "ToolError",
47
+ "consume_stream",
48
+ "ensure_running",
49
+ "load_config",
50
+ "run_in_worker",
51
+ "serve",
52
+ "setup_logging",
53
+ "to_grpc_status",
54
+ "wrap_unknown",
55
+ ]
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.1.3'
22
+ __version_tuple__ = version_tuple = (0, 1, 3)
23
+
24
+ __commit_id__ = commit_id = None
mmo_runtime/client.py ADDED
@@ -0,0 +1,294 @@
1
+ """gRPC client with deadline, stream consumption, and ensure_running."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ import subprocess
8
+ import sys
9
+ import threading
10
+ import time
11
+ from collections.abc import Callable, Iterator, Sequence
12
+ from pathlib import Path
13
+ from typing import TYPE_CHECKING, Any, NoReturn, Self, TypeVar
14
+
15
+ import grpc
16
+ from grpc_health.v1 import health_pb2, health_pb2_grpc
17
+
18
+ from .errors import Cancelled, DeadlineExceeded, ErrorCode, Internal, ToolError
19
+ from .events import ProgressEvent, ResultEnvelope
20
+ from .transport import normalize_target
21
+
22
+ if TYPE_CHECKING:
23
+ from types import TracebackType
24
+
25
+ T = TypeVar("T")
26
+
27
+
28
+ def _get_target_address(socket_path: str) -> str:
29
+ """Determine gRPC target address for the current platform.
30
+
31
+ On Windows ``socket_path`` is a TCP ``host:port`` (grpc Python has no
32
+ ``windows://`` named-pipe resolver). On Unix it is a Unix domain socket path.
33
+ """
34
+ return normalize_target(socket_path)
35
+
36
+
37
+ def consume_stream(
38
+ stream: Iterator[Any],
39
+ on_progress: Callable[[ProgressEvent], None] | None = None,
40
+ ) -> ResultEnvelope:
41
+ """
42
+ Consume a server-streaming RPC, yielding progress and returning final result.
43
+ Raises ToolError on error status or error envelope.
44
+ """
45
+ result: ResultEnvelope = ResultEnvelope(payload=None)
46
+ has_result = False
47
+ for msg in stream:
48
+ # Check for progress/result/error oneof
49
+ if hasattr(msg, "WhichOneof"):
50
+ oneof = msg.WhichOneof("payload")
51
+ if oneof == "progress":
52
+ prog = msg.progress
53
+ event = ProgressEvent(
54
+ step=prog.step,
55
+ done=prog.done,
56
+ total=prog.total if prog.total > 0 else None,
57
+ message=prog.message if prog.message else None,
58
+ meta=dict(prog.meta) if hasattr(prog, "meta") else {},
59
+ )
60
+ if on_progress:
61
+ on_progress(event)
62
+ elif oneof == "result":
63
+ result = ResultEnvelope(
64
+ payload=msg.result.payload if hasattr(msg.result, "payload") else msg.result,
65
+ artifacts=list(msg.result.artifacts)
66
+ if hasattr(msg.result, "artifacts")
67
+ else [],
68
+ meta=dict(msg.result.meta) if hasattr(msg.result, "meta") else {},
69
+ )
70
+ has_result = True
71
+ elif oneof == "error":
72
+ err = msg.error
73
+ raise ToolError(
74
+ code=err.code,
75
+ step=err.step if err.step else None,
76
+ provider=err.provider if err.provider else None,
77
+ message=err.message,
78
+ retryable=err.retryable,
79
+ )
80
+ else:
81
+ # Fallback: assume message is result
82
+ result = ResultEnvelope(payload=msg)
83
+ has_result = True
84
+
85
+ if not has_result:
86
+ raise Internal("Stream ended without result")
87
+ return result
88
+
89
+
90
+ class ToolClient:
91
+ """Client for tool gRPC service."""
92
+
93
+ def __init__(
94
+ self,
95
+ socket_path: str,
96
+ default_deadline: float = 30.0,
97
+ health_check_timeout: float = 5.0,
98
+ ):
99
+ self.socket_path = socket_path
100
+ self.default_deadline = default_deadline
101
+ self.health_check_timeout = health_check_timeout
102
+ self._channel: grpc.Channel | None = None
103
+ self._health_stub: health_pb2_grpc.HealthStub | None = None
104
+ self._log = logging.getLogger(__name__)
105
+ self._channel_lock = threading.RLock()
106
+
107
+ @property
108
+ def channel(self) -> grpc.Channel:
109
+ """Public lazily-created channel for generated service stubs."""
110
+ return self._ensure_channel()
111
+
112
+ def _ensure_channel(self) -> grpc.Channel:
113
+ with self._channel_lock:
114
+ if self._channel is None:
115
+ target = _get_target_address(self.socket_path)
116
+ self._channel = grpc.insecure_channel(target)
117
+ self._health_stub = health_pb2_grpc.HealthStub(self._channel)
118
+ return self._channel
119
+
120
+ def close(self) -> None:
121
+ with self._channel_lock:
122
+ if self._channel is not None:
123
+ self._channel.close()
124
+ self._channel = None
125
+ self._health_stub = None
126
+
127
+ def __enter__(self) -> Self:
128
+ return self
129
+
130
+ def __exit__(
131
+ self,
132
+ exc_type: type[BaseException] | None,
133
+ exc_val: BaseException | None,
134
+ exc_tb: TracebackType | None,
135
+ ) -> None:
136
+ self.close()
137
+
138
+ def health_check(
139
+ self,
140
+ service: str = "",
141
+ *,
142
+ timeout: float | None = None,
143
+ ) -> health_pb2.HealthCheckResponse.ServingStatus:
144
+ self._ensure_channel()
145
+ try:
146
+ resp: health_pb2.HealthCheckResponse = self._health_stub.Check( # type: ignore[attr-defined]
147
+ health_pb2.HealthCheckRequest(service=service),
148
+ timeout=self.health_check_timeout if timeout is None else timeout,
149
+ )
150
+ # grpc_health pb2 ships untyped; status enum member is Unknown to pyright
151
+ return resp.status # type: ignore[return-value]
152
+ except grpc.RpcError as e:
153
+ if e.code() in (grpc.StatusCode.UNAVAILABLE, grpc.StatusCode.DEADLINE_EXCEEDED):
154
+ return health_pb2.HealthCheckResponse.ServingStatus.SERVICE_UNKNOWN
155
+ raise
156
+
157
+ def wait_healthy(self, timeout: float = 30.0, interval: float = 0.5) -> bool:
158
+ deadline = time.monotonic() + timeout
159
+ while time.monotonic() < deadline:
160
+ remaining = min(timeout, max(0, deadline - time.monotonic()))
161
+ if (
162
+ self.health_check(timeout=min(self.health_check_timeout, remaining))
163
+ == health_pb2.HealthCheckResponse.ServingStatus.SERVING
164
+ ):
165
+ return True
166
+ time.sleep(max(0, min(interval, deadline - time.monotonic())))
167
+ return False
168
+
169
+ def call_unary(
170
+ self,
171
+ method: Callable[..., Any],
172
+ request: Any,
173
+ deadline: float | None = None,
174
+ *,
175
+ metadata: Sequence[tuple[str, str | bytes]] | None = None,
176
+ ) -> Any:
177
+ """Call a unary RPC with deadline."""
178
+ self._ensure_channel()
179
+ try:
180
+ kwargs: dict[str, Any] = {
181
+ "timeout": self.default_deadline if deadline is None else deadline,
182
+ }
183
+ if metadata is not None:
184
+ kwargs["metadata"] = metadata
185
+ return method(request, **kwargs)
186
+ except grpc.RpcError as e:
187
+ self._handle_grpc_error(e)
188
+
189
+ def call_stream(
190
+ self,
191
+ method: Callable[..., Iterator[Any]],
192
+ request: Any,
193
+ on_progress: Callable[[ProgressEvent], None] | None = None,
194
+ deadline: float | None = None,
195
+ *,
196
+ metadata: Sequence[tuple[str, str | bytes]] | None = None,
197
+ ) -> ResultEnvelope:
198
+ """Call a server-streaming RPC with deadline."""
199
+ self._ensure_channel()
200
+ try:
201
+ kwargs: dict[str, Any] = {
202
+ "timeout": self.default_deadline if deadline is None else deadline,
203
+ }
204
+ if metadata is not None:
205
+ kwargs["metadata"] = metadata
206
+ stream = method(request, **kwargs)
207
+ try:
208
+ return consume_stream(stream, on_progress)
209
+ finally:
210
+ cancel = getattr(stream, "cancel", None)
211
+ if callable(cancel):
212
+ cancel()
213
+ except grpc.RpcError as e:
214
+ self._handle_grpc_error(e)
215
+
216
+ def _handle_grpc_error(self, e: grpc.RpcError) -> NoReturn:
217
+ status = e.code()
218
+ message = e.details() or f"RPC error: {status}"
219
+ if status == grpc.StatusCode.DEADLINE_EXCEEDED:
220
+ raise DeadlineExceeded(message) from e
221
+ if status == grpc.StatusCode.CANCELLED:
222
+ raise Cancelled(message) from e
223
+ code = {
224
+ grpc.StatusCode.INVALID_ARGUMENT: ErrorCode.INVALID_REQUEST,
225
+ grpc.StatusCode.RESOURCE_EXHAUSTED: ErrorCode.RESOURCE_EXHAUSTED,
226
+ grpc.StatusCode.UNKNOWN: ErrorCode.PROVIDER_ERROR,
227
+ grpc.StatusCode.UNAVAILABLE: ErrorCode.PROVIDER_ERROR,
228
+ }.get(status, ErrorCode.INTERNAL)
229
+ raise ToolError(
230
+ code=code,
231
+ message=message,
232
+ retryable=status in (grpc.StatusCode.RESOURCE_EXHAUSTED, grpc.StatusCode.UNAVAILABLE),
233
+ ) from e
234
+
235
+
236
+ # Process management for ensure_running
237
+ _spawn_locks: dict[str, threading.Lock] = {}
238
+ _spawn_locks_lock = threading.Lock()
239
+
240
+
241
+ def get_spawn_lock(socket_path: str) -> threading.Lock:
242
+ with _spawn_locks_lock:
243
+ if socket_path not in _spawn_locks:
244
+ _spawn_locks[socket_path] = threading.Lock()
245
+ return _spawn_locks[socket_path]
246
+
247
+
248
+ def ensure_running(
249
+ tool_name: str,
250
+ command: list[str],
251
+ socket_path: str,
252
+ timeout: float = 30.0,
253
+ env: dict[str, str] | None = None,
254
+ cwd: Path | None = None,
255
+ ) -> ToolClient:
256
+ """
257
+ Ensure tool server is running. Spawns if not healthy.
258
+ Uses a per-socket lock to avoid duplicate spawns.
259
+ """
260
+ client = ToolClient(socket_path)
261
+ if client.wait_healthy(timeout=2.0):
262
+ return client
263
+
264
+ lock = get_spawn_lock(socket_path)
265
+ with lock:
266
+ # Double-check after acquiring lock
267
+ if client.wait_healthy(timeout=2.0):
268
+ return client
269
+
270
+ # Spawn server
271
+ proc_env = os.environ.copy()
272
+ if env:
273
+ proc_env.update(env)
274
+ # On Windows, use CREATE_NEW_PROCESS_GROUP for proper termination
275
+ creationflags = 0
276
+ if sys.platform == "win32":
277
+ creationflags = subprocess.CREATE_NEW_PROCESS_GROUP
278
+ proc = subprocess.Popen(
279
+ command,
280
+ env=proc_env,
281
+ cwd=cwd,
282
+ creationflags=creationflags,
283
+ )
284
+
285
+ # Wait for healthy
286
+ if not client.wait_healthy(timeout=timeout):
287
+ proc.terminate()
288
+ try:
289
+ proc.wait(timeout=5)
290
+ except subprocess.TimeoutExpired:
291
+ proc.kill()
292
+ raise RuntimeError(f"Tool {tool_name} failed to start within {timeout}s")
293
+
294
+ return client
mmo_runtime/config.py ADDED
@@ -0,0 +1,90 @@
1
+ """Configuration loading with precedence: defaults < yaml < env < CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from pydantic import Field, field_validator
11
+ from pydantic_settings import BaseSettings, SettingsConfigDict
12
+
13
+
14
+ class BaseServerConfig(BaseSettings):
15
+ """Base configuration for tool servers."""
16
+
17
+ model_config = SettingsConfigDict(
18
+ env_prefix="TOOL_",
19
+ env_nested_delimiter="__",
20
+ case_sensitive=False,
21
+ extra="ignore",
22
+ )
23
+
24
+ # Socket
25
+ socket_path: str = Field(
26
+ default_factory=lambda: "127.0.0.1:50051" if sys.platform == "win32" else "/tmp/tool.sock",
27
+ description="gRPC host:port or Unix domain socket path",
28
+ )
29
+ # Concurrency
30
+ max_concurrent_jobs: int = Field(default=1, ge=1, description="Max concurrent jobs")
31
+ max_queue_size: int = Field(default=4, ge=0, description="Max queued jobs (0 = no queue)")
32
+
33
+ @property
34
+ def thread_pool_size(self) -> int:
35
+ """Total thread pool size: workers + queue + buffer for health/gRPC internals."""
36
+ return self.max_concurrent_jobs + self.max_queue_size + 4
37
+
38
+ # Timeouts
39
+ idle_timeout_sec: int = Field(
40
+ default=300, ge=0, description="Idle timeout before shutdown (0 = disabled)"
41
+ )
42
+ job_timeout_sec: int = Field(default=0, ge=0, description="Per-job timeout (0 = no limit)")
43
+ # Device
44
+ device: str = Field(default="cpu", description="Compute device (cpu, cuda, mps, etc.)")
45
+ # Paths
46
+ runs_root: Path = Field(default=Path("runs"), description="Root directory for run outputs")
47
+ # Logging
48
+ log_level: str = Field(default="INFO", description="Log level")
49
+ log_json: bool = Field(default=False, description="Output JSON logs")
50
+
51
+ @field_validator("runs_root", mode="before")
52
+ @classmethod
53
+ def expand_runs_root(cls, v: str | Path) -> Path:
54
+ return Path(v).expanduser().resolve()
55
+
56
+ @field_validator("socket_path", mode="before")
57
+ @classmethod
58
+ def expand_socket_path(cls, v: str) -> str:
59
+ return os.path.expanduser(v)
60
+
61
+
62
+ def load_config(
63
+ config_cls: type[BaseServerConfig],
64
+ yaml_path: Path | None = None,
65
+ cli_overrides: dict[str, Any] | None = None,
66
+ ) -> BaseServerConfig:
67
+ """
68
+ Load config with precedence: defaults < yaml < env < CLI.
69
+ """
70
+ yaml_data: dict[str, Any] = {}
71
+ if yaml_path and yaml_path.exists():
72
+ import yaml
73
+
74
+ with open(yaml_path) as f:
75
+ yaml_data = yaml.safe_load(f) or {}
76
+
77
+ # pydantic-settings ranks init kwargs above env vars, so yaml and CLI
78
+ # (both passed as init kwargs) would each beat env. To get yaml < env < CLI:
79
+ # drop yaml keys that the environment also provides; keep CLI keys so they
80
+ # win over env. Only flat (non-nested) yaml keys are env-matched.
81
+ cli = {k: v for k, v in (cli_overrides or {}).items() if v is not None}
82
+ merged: dict[str, Any] = {**yaml_data, **cli}
83
+
84
+ prefix = str(config_cls.model_config.get("env_prefix", ""))
85
+ env_keys = {k.upper() for k in os.environ}
86
+ for key in yaml_data:
87
+ if key not in cli and f"{prefix}{key}".upper() in env_keys:
88
+ merged.pop(key)
89
+
90
+ return config_cls(**merged)
mmo_runtime/context.py ADDED
@@ -0,0 +1,109 @@
1
+ """Context management: cancellation, progress reporting, run context."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import threading
7
+ from pathlib import Path
8
+ from queue import Full, Queue
9
+ from typing import Any
10
+
11
+ from .events import ProgressEvent
12
+
13
+
14
+ class CancelToken:
15
+ """Thread-safe cancellation token."""
16
+
17
+ def __init__(self):
18
+ self._event = threading.Event()
19
+
20
+ def set(self) -> None:
21
+ self._event.set()
22
+
23
+ def is_set(self) -> bool:
24
+ return self._event.is_set()
25
+
26
+ def raise_if_cancelled(self) -> None:
27
+ if self._event.is_set():
28
+ from .errors import Cancelled
29
+
30
+ raise Cancelled("Operation cancelled")
31
+
32
+ def wait(self, timeout: float | None = None) -> bool:
33
+ return self._event.wait(timeout)
34
+
35
+
36
+ class ProgressReporter:
37
+ """Progress reporter interface. Default implementation is no-op."""
38
+
39
+ def update(
40
+ self,
41
+ step: str,
42
+ done: int,
43
+ total: int | None = None,
44
+ message: str | None = None,
45
+ **meta: Any,
46
+ ) -> None:
47
+ pass
48
+
49
+
50
+ class QueueProgressReporter(ProgressReporter):
51
+ """Progress reporter that pushes events to a queue for server-streaming."""
52
+
53
+ def __init__(self, queue: Queue[ProgressEvent], *, cancel: CancelToken | None = None):
54
+ self._queue = queue
55
+ self._cancel = cancel
56
+ self._closed = threading.Event()
57
+
58
+ def close(self) -> None:
59
+ """Unblock a producer when its consumer has gone away."""
60
+ self._closed.set()
61
+
62
+ def update(
63
+ self,
64
+ step: str,
65
+ done: int,
66
+ total: int | None = None,
67
+ message: str | None = None,
68
+ **meta: Any,
69
+ ) -> None:
70
+ event = ProgressEvent(step=step, done=done, total=total, message=message, meta=meta)
71
+ while not self._closed.is_set():
72
+ if self._cancel is not None:
73
+ self._cancel.raise_if_cancelled()
74
+ try:
75
+ self._queue.put(event, timeout=0.05)
76
+ return
77
+ except Full:
78
+ continue
79
+ from .errors import Cancelled
80
+
81
+ raise Cancelled("Progress consumer closed")
82
+
83
+
84
+ class RunContext:
85
+ """Execution context for a single tool run."""
86
+
87
+ def __init__(
88
+ self,
89
+ run_id: str,
90
+ out_dir: Path,
91
+ progress: ProgressReporter | None = None,
92
+ cancel: CancelToken | None = None,
93
+ log: logging.Logger | None = None,
94
+ ):
95
+ self.run_id = run_id
96
+ self.out_dir = out_dir
97
+ self.progress = progress or ProgressReporter()
98
+ self.cancel = cancel or CancelToken()
99
+ self.log = log or logging.getLogger(__name__)
100
+
101
+ def child(self, step: str) -> RunContext:
102
+ """Create a child context with a step prefix."""
103
+ return RunContext(
104
+ run_id=self.run_id,
105
+ out_dir=self.out_dir / step,
106
+ progress=self.progress,
107
+ cancel=self.cancel,
108
+ log=self.log.getChild(step),
109
+ )
mmo_runtime/errors.py ADDED
@@ -0,0 +1,125 @@
1
+ """Error definitions for mmo-runtime."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import Enum
6
+
7
+ import grpc
8
+
9
+
10
+ class ErrorCode(str, Enum):
11
+ INVALID_REQUEST = "INVALID_REQUEST"
12
+ PROVIDER_ERROR = "PROVIDER_ERROR"
13
+ CANCELLED = "CANCELLED"
14
+ DEADLINE_EXCEEDED = "DEADLINE_EXCEEDED"
15
+ INTERNAL = "INTERNAL"
16
+ RESOURCE_EXHAUSTED = "RESOURCE_EXHAUSTED"
17
+
18
+
19
+ _CODE_TO_GRPC = {
20
+ ErrorCode.INVALID_REQUEST: grpc.StatusCode.INVALID_ARGUMENT,
21
+ ErrorCode.PROVIDER_ERROR: grpc.StatusCode.UNKNOWN,
22
+ ErrorCode.CANCELLED: grpc.StatusCode.CANCELLED,
23
+ ErrorCode.DEADLINE_EXCEEDED: grpc.StatusCode.DEADLINE_EXCEEDED,
24
+ ErrorCode.INTERNAL: grpc.StatusCode.INTERNAL,
25
+ ErrorCode.RESOURCE_EXHAUSTED: grpc.StatusCode.RESOURCE_EXHAUSTED,
26
+ }
27
+
28
+
29
+ class ToolError(Exception):
30
+ """Base error for tool operations."""
31
+
32
+ def __init__(
33
+ self,
34
+ code: ErrorCode | str,
35
+ step: str | None = None,
36
+ provider: str | None = None,
37
+ message: str = "",
38
+ retryable: bool = False,
39
+ cause: Exception | None = None,
40
+ ):
41
+ super().__init__(message)
42
+ try:
43
+ self.code = ErrorCode(code)
44
+ except ValueError:
45
+ self.code = ErrorCode.INTERNAL
46
+ self.step = step
47
+ self.provider = provider
48
+ self.retryable = retryable
49
+ self.cause = cause
50
+
51
+ def __str__(self) -> str:
52
+ parts = [f"[{self.code.value}]"]
53
+ if self.step:
54
+ parts.append(f"step={self.step}")
55
+ if self.provider:
56
+ parts.append(f"provider={self.provider}")
57
+ parts.append(super().__str__())
58
+ return " ".join(parts)
59
+
60
+ def to_grpc_status(self) -> tuple[grpc.StatusCode, str]:
61
+ status = _CODE_TO_GRPC.get(self.code, grpc.StatusCode.INTERNAL)
62
+ return status, str(self)
63
+
64
+
65
+ class InvalidRequest(ToolError):
66
+ def __init__(
67
+ self,
68
+ message: str,
69
+ step: str | None = None,
70
+ provider: str | None = None,
71
+ cause: Exception | None = None,
72
+ ):
73
+ super().__init__(
74
+ ErrorCode.INVALID_REQUEST, step, provider, message, retryable=False, cause=cause
75
+ )
76
+
77
+
78
+ class ProviderError(ToolError):
79
+ def __init__(
80
+ self,
81
+ message: str,
82
+ step: str | None = None,
83
+ provider: str | None = None,
84
+ cause: Exception | None = None,
85
+ retryable: bool = True,
86
+ ):
87
+ super().__init__(
88
+ ErrorCode.PROVIDER_ERROR, step, provider, message, retryable=retryable, cause=cause
89
+ )
90
+
91
+
92
+ class Cancelled(ToolError):
93
+ def __init__(self, message: str = "Operation cancelled", step: str | None = None):
94
+ super().__init__(ErrorCode.CANCELLED, step, None, message, retryable=False)
95
+
96
+
97
+ class DeadlineExceeded(ToolError):
98
+ def __init__(self, message: str = "Deadline exceeded", step: str | None = None):
99
+ super().__init__(ErrorCode.DEADLINE_EXCEEDED, step, None, message, retryable=True)
100
+
101
+
102
+ class Internal(ToolError):
103
+ def __init__(
104
+ self,
105
+ message: str = "Internal error",
106
+ step: str | None = None,
107
+ cause: Exception | None = None,
108
+ ):
109
+ super().__init__(ErrorCode.INTERNAL, step, None, message, retryable=False, cause=cause)
110
+
111
+
112
+ class ResourceExhausted(ToolError):
113
+ def __init__(
114
+ self, message: str = "Resource exhausted", step: str | None = None, retryable: bool = True
115
+ ):
116
+ super().__init__(ErrorCode.RESOURCE_EXHAUSTED, step, None, message, retryable=retryable)
117
+
118
+
119
+ def to_grpc_status(err: ToolError) -> tuple[grpc.StatusCode, str]:
120
+ return err.to_grpc_status()
121
+
122
+
123
+ def wrap_unknown(exc: BaseException, step: str | None = None) -> Internal:
124
+ """Wrap an unknown exception as Internal, hiding traceback from client."""
125
+ return Internal(f"Unexpected error: {type(exc).__name__}", step=step, cause=exc) # type: ignore[arg-type]