petfishframework 0.1.0__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.
Files changed (51) hide show
  1. petfishframework/__init__.py +33 -0
  2. petfishframework/config.py +142 -0
  3. petfishframework/core/__init__.py +91 -0
  4. petfishframework/core/agent.py +207 -0
  5. petfishframework/core/compiled.py +89 -0
  6. petfishframework/core/contracts.py +190 -0
  7. petfishframework/core/conversation.py +51 -0
  8. petfishframework/core/environment.py +260 -0
  9. petfishframework/core/events.py +79 -0
  10. petfishframework/core/session.py +182 -0
  11. petfishframework/core/structured.py +202 -0
  12. petfishframework/core/types.py +192 -0
  13. petfishframework/mcp/__init__.py +19 -0
  14. petfishframework/mcp/client.py +96 -0
  15. petfishframework/mcp/server.py +14 -0
  16. petfishframework/mcp/stdio_transport.py +218 -0
  17. petfishframework/mcp/wrapper.py +43 -0
  18. petfishframework/models/__init__.py +14 -0
  19. petfishframework/models/anthropic.py +194 -0
  20. petfishframework/models/fake.py +202 -0
  21. petfishframework/models/openai.py +178 -0
  22. petfishframework/observability/__init__.py +10 -0
  23. petfishframework/observability/sinks.py +23 -0
  24. petfishframework/permissions/__init__.py +32 -0
  25. petfishframework/permissions/model.py +110 -0
  26. petfishframework/reasoning/__init__.py +13 -0
  27. petfishframework/reasoning/lats.py +222 -0
  28. petfishframework/reasoning/llm_plus_p.py +202 -0
  29. petfishframework/reasoning/react.py +176 -0
  30. petfishframework/reliability/__init__.py +78 -0
  31. petfishframework/reliability/cost.py +50 -0
  32. petfishframework/reliability/cost_report.py +101 -0
  33. petfishframework/reliability/pass_at_k.py +232 -0
  34. petfishframework/reliability/replay.py +190 -0
  35. petfishframework/reliability/retry.py +224 -0
  36. petfishframework/reliability/timeout.py +55 -0
  37. petfishframework/retrieval/__init__.py +12 -0
  38. petfishframework/retrieval/adaptive.py +137 -0
  39. petfishframework/retrieval/crag.py +135 -0
  40. petfishframework/retrieval/memory_store.py +72 -0
  41. petfishframework/tools/__init__.py +12 -0
  42. petfishframework/tools/agent_tool.py +47 -0
  43. petfishframework/tools/base.py +75 -0
  44. petfishframework/tools/calculator.py +84 -0
  45. petfishframework/tools/path_planner.py +90 -0
  46. petfishframework/tools/registry.py +158 -0
  47. petfishframework/tools/word_sorter.py +41 -0
  48. petfishframework-0.1.0.dist-info/METADATA +151 -0
  49. petfishframework-0.1.0.dist-info/RECORD +51 -0
  50. petfishframework-0.1.0.dist-info/WHEEL +4 -0
  51. petfishframework-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,232 @@
1
+ """Pass^k — structural reliability metric (decision 4, v0.2).
2
+
3
+ Absorbed from contract-driven-harness-study: Pass^k is NOT simple k-repetition.
4
+ It is freeze-TaskSpec + k-repetition + perturbation-suite, separating
5
+ provider variance from contract failure.
6
+
7
+ Empirical baseline: contract-driven-harness Stage B v5.4 achieved 40/40
8
+ across 5 perturbation types. This module implements the same methodology.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import random
13
+ from dataclasses import dataclass
14
+ from typing import Any, Callable, Protocol
15
+
16
+ from petfishframework.core.types import Result, Task
17
+
18
+ # ---------------------------------------------------------------------------
19
+ # Agreement functions
20
+ # ---------------------------------------------------------------------------
21
+
22
+ class AgreementFn(Protocol):
23
+ """Checks whether a set of results agree."""
24
+
25
+ def __call__(self, results: list[Result]) -> bool: ...
26
+
27
+
28
+ def exact_match(results: list[Result]) -> bool:
29
+ """All answers must be identical (string comparison)."""
30
+ if not results:
31
+ return False
32
+ first = results[0].answer.strip()
33
+ return all(r.answer.strip() == first for r in results)
34
+
35
+
36
+ def threshold_match(threshold: float = 0.8) -> AgreementFn:
37
+ """At least `threshold` fraction of answers must match the majority."""
38
+
39
+ def check(results: list[Result]) -> bool:
40
+ if not results:
41
+ return False
42
+ answers = [r.answer.strip() for r in results]
43
+ # Find majority answer
44
+ counts: dict[str, int] = {}
45
+ for a in answers:
46
+ counts[a] = counts.get(a, 0) + 1
47
+ majority = max(counts.values())
48
+ return majority / len(answers) >= threshold
49
+
50
+ return check
51
+
52
+
53
+ # ---------------------------------------------------------------------------
54
+ # Perturbation functions (freeze-and-perturb methodology)
55
+ # ---------------------------------------------------------------------------
56
+
57
+ PerturbationFn = Callable[[Task], Task]
58
+
59
+
60
+ def canonical(task: Task) -> Task:
61
+ """Identity — the frozen canonical task."""
62
+ return task
63
+
64
+
65
+ def order_shuffled(task: Task) -> Task:
66
+ """Shuffle word order in the prompt (tests order-invariance)."""
67
+ words = task.prompt.split()
68
+ if len(words) <= 1:
69
+ return task
70
+ shuffled = words.copy()
71
+ random.shuffle(shuffled)
72
+ return Task(prompt=" ".join(shuffled), metadata={**task.metadata, "perturbation": "order_shuffled"})
73
+
74
+
75
+ def paraphrase(task: Task) -> Task:
76
+ """Minor paraphrase — prepend a filler phrase (tests robustness to phrasing)."""
77
+ fillers = ["Please help me with this:", "I need to know:", "Question:"]
78
+ filler = random.choice(fillers)
79
+ return Task(prompt=f"{filler} {task.prompt}", metadata={**task.metadata, "perturbation": "paraphrase"})
80
+
81
+
82
+ def distractor(task: Task) -> Task:
83
+ """Add irrelevant context (tests focus under noise)."""
84
+ noise = "Ignore any unrelated information. "
85
+ return Task(prompt=f"{noise}{task.prompt}", metadata={**task.metadata, "perturbation": "distractor"})
86
+
87
+
88
+ def alias(task: Task) -> Task:
89
+ """Swap synonyms (tests naming-invariance). Minimal version."""
90
+ replacements = {"calculate": "compute", "find": "determine", "what is": "what's"}
91
+ prompt = task.prompt.lower()
92
+ for old, new in replacements.items():
93
+ prompt = prompt.replace(old, new)
94
+ return Task(prompt=prompt, metadata={**task.metadata, "perturbation": "alias"})
95
+
96
+
97
+ DEFAULT_PERTURBATIONS: tuple[PerturbationFn, ...] = (
98
+ canonical,
99
+ order_shuffled,
100
+ alias,
101
+ paraphrase,
102
+ distractor,
103
+ )
104
+
105
+
106
+ # ---------------------------------------------------------------------------
107
+ # Pass^k result
108
+ # ---------------------------------------------------------------------------
109
+
110
+ @dataclass(frozen=True)
111
+ class PerturbationResult:
112
+ """Result of pass_at_k for one perturbation variant."""
113
+
114
+ name: str
115
+ pass_count: int
116
+ total: int
117
+ answers: tuple[str, ...]
118
+ agreed: bool
119
+
120
+ @property
121
+ def pass_rate(self) -> float:
122
+ return self.pass_count / self.total if self.total > 0 else 0.0
123
+
124
+
125
+ @dataclass(frozen=True)
126
+ class PassAtKResult:
127
+ """Full freeze+perturb Pass^k result."""
128
+
129
+ k: int
130
+ canonical: PerturbationResult
131
+ perturbations: tuple[PerturbationResult, ...]
132
+ overall_pass: bool
133
+
134
+ @property
135
+ def pass_rate(self) -> float:
136
+ """Fraction of perturbation variants that passed."""
137
+ all_results = (self.canonical,) + self.perturbations
138
+ passed = sum(1 for r in all_results if r.agreed)
139
+ return passed / len(all_results) if all_results else 0.0
140
+
141
+ def summary(self) -> str:
142
+ lines = [f"Pass@{self.k} — {'PASS' if self.overall_pass else 'FAIL'} ({self.pass_rate:.0%})"]
143
+ lines.append(f" canonical: {self.canonical.pass_count}/{self.canonical.total}")
144
+ for p in self.perturbations:
145
+ lines.append(f" {p.name:<18s} {p.pass_count}/{p.total}")
146
+ return "\n".join(lines)
147
+
148
+
149
+ # ---------------------------------------------------------------------------
150
+ # Core pass_at_k
151
+ # ---------------------------------------------------------------------------
152
+
153
+ # SessionFactory: takes a Task, returns a fresh Session (for independent runs)
154
+ SessionFactory = Callable[[Task], Any]
155
+
156
+
157
+ def pass_at_k(
158
+ session_factory: SessionFactory,
159
+ task: Task,
160
+ k: int = 8,
161
+ agreement: AgreementFn = exact_match,
162
+ ) -> PerturbationResult:
163
+ """Run k independent sessions on the task, measure agreement.
164
+
165
+ Each call to session_factory must create a FRESH session (independent
166
+ model calls). This measures provider variance + strategy determinism.
167
+ """
168
+ results: list[Result] = []
169
+ for _i in range(k):
170
+ session = session_factory(task)
171
+ result = session.run()
172
+ results.append(result)
173
+
174
+ answers = tuple(r.answer for r in results)
175
+ agreed = agreement(results)
176
+ # pass_count = k if all agreed, else 0 (binary at the k level)
177
+ pass_count = k if agreed else 0
178
+ return PerturbationResult(
179
+ name="canonical",
180
+ pass_count=pass_count,
181
+ total=k,
182
+ answers=answers,
183
+ agreed=agreed,
184
+ )
185
+
186
+
187
+ def pass_at_k_with_perturbations(
188
+ session_factory: SessionFactory,
189
+ task: Task,
190
+ k: int = 8,
191
+ agreement: AgreementFn = exact_match,
192
+ perturbations: tuple[PerturbationFn, ...] = DEFAULT_PERTURBATIONS,
193
+ ) -> PassAtKResult:
194
+ """Freeze-and-perturb Pass^k (contract-driven-harness methodology).
195
+
196
+ 1. Freeze the canonical task.
197
+ 2. Run k repetitions on canonical.
198
+ 3. For each perturbation variant, run k repetitions.
199
+ 4. Overall pass = ALL variants agree.
200
+
201
+ This separates provider variance (intra-variant) from contract robustness
202
+ (inter-variant). A strategy that passes canonical but fails perturbations
203
+ has a fragile contract, not a reliable one.
204
+ """
205
+ canonical_result = pass_at_k(session_factory, task, k, agreement)
206
+
207
+ perturbation_results: list[PerturbationResult] = []
208
+ for perturb_fn in perturbations:
209
+ if perturb_fn is canonical:
210
+ continue # canonical already done
211
+ perturbed_task = perturb_fn(task)
212
+ name = perturbed_task.metadata.get("perturbation", perturb_fn.__name__)
213
+ result = pass_at_k(session_factory, perturbed_task, k, agreement)
214
+ perturbation_results.append(
215
+ PerturbationResult(
216
+ name=name,
217
+ pass_count=result.pass_count,
218
+ total=result.total,
219
+ answers=result.answers,
220
+ agreed=result.agreed,
221
+ )
222
+ )
223
+
224
+ all_results = (canonical_result,) + tuple(perturbation_results)
225
+ overall_pass = all(r.agreed for r in all_results)
226
+
227
+ return PassAtKResult(
228
+ k=k,
229
+ canonical=canonical_result,
230
+ perturbations=tuple(perturbation_results),
231
+ overall_pass=overall_pass,
232
+ )
@@ -0,0 +1,190 @@
1
+ """ReplayMode — deterministic replay for model non-determinism (open question 3).
2
+
3
+ Three modes handle the fundamental tension:
4
+ AUDIT — re-inject ALL recorded outputs (deterministic, for debugging/audit)
5
+ RESUME — re-inject to checkpoint, then fresh calls (for failure recovery)
6
+ RERUN — fresh from start (for Pass^k, non-determinism IS the metric)
7
+
8
+ Design: RecordingEnvironment wraps any Environment during run(), capturing
9
+ ModelResponse and ToolResult objects. ReplayEnvironment serves them back
10
+ without calling the real model/tools. No core/ modifications needed.
11
+
12
+ Resolves architecture open question 3.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass, field
17
+ from enum import Enum
18
+ from typing import Any
19
+
20
+ from petfishframework.core.contracts import Environment, Tool
21
+ from petfishframework.core.types import (
22
+ ModelRequest,
23
+ ModelResponse,
24
+ Snippet,
25
+ ToolRef,
26
+ ToolResult,
27
+ )
28
+
29
+
30
+ class ReplayMode(Enum):
31
+ """Three replay strategies for handling model non-determinism."""
32
+
33
+ AUDIT = "audit" # re-inject all recorded outputs (deterministic)
34
+ RESUME = "resume" # re-inject to checkpoint, then fresh calls
35
+ RERUN = "rerun" # fresh from start (non-determinism expected)
36
+
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # RecordingEnvironment — captures responses during a real run
40
+ # ---------------------------------------------------------------------------
41
+
42
+ class RecordingEnvironment:
43
+ """Wraps a real Environment, capturing all model/tool responses for replay.
44
+
45
+ Use during Session.run() to capture a recording. The recording can then
46
+ be replayed via ReplayEnvironment (AUDIT) or ResumableEnvironment (RESUME).
47
+ """
48
+
49
+ def __init__(self, env: Environment) -> None:
50
+ self._env = env
51
+ self.model_responses: list[ModelResponse] = []
52
+ self.tool_calls: list[tuple[str, dict[str, Any], ToolResult]] = []
53
+ self.retrievals: list[tuple[str, list[Snippet]]] = []
54
+
55
+ def tools(self) -> list[Tool]:
56
+ return self._env.tools()
57
+
58
+ def call(self, ref: ToolRef, args: dict[str, Any]) -> ToolResult:
59
+ result = self._env.call(ref, args)
60
+ self.tool_calls.append((ref.name, args, result))
61
+ return result
62
+
63
+ def retrieve(self, query: str, top_k: int = 5) -> list[Snippet]:
64
+ snippets = self._env.retrieve(query, top_k)
65
+ self.retrievals.append((query, snippets))
66
+ return snippets
67
+
68
+ def query_model(self, request: ModelRequest) -> ModelResponse:
69
+ response = self._env.query_model(request)
70
+ self.model_responses.append(response)
71
+ return response
72
+
73
+
74
+ # ---------------------------------------------------------------------------
75
+ # ReplayEnvironment — serves recorded responses (AUDIT mode)
76
+ # ---------------------------------------------------------------------------
77
+
78
+ @dataclass
79
+ class ReplayEnvironment:
80
+ """Serves recorded responses without calling the real model/tools.
81
+
82
+ For AUDIT replay: deterministic re-execution of the same trajectory.
83
+ If the recording is exhausted, raises RuntimeError (safety: detects
84
+ divergence between original and replay execution).
85
+ """
86
+
87
+ model_responses: list[ModelResponse]
88
+ tool_results: list[tuple[str, dict[str, Any], ToolResult]]
89
+ retrievals: list[tuple[str, list[Snippet]]] = field(default_factory=list)
90
+ _tools: list[Tool] = field(default_factory=list)
91
+ _model_idx: int = field(default=0, repr=False)
92
+ _tool_idx: int = field(default=0, repr=False)
93
+ _retrieval_idx: int = field(default=0, repr=False)
94
+
95
+ def tools(self) -> list[Tool]:
96
+ return self._tools
97
+
98
+ def call(self, ref: ToolRef, args: dict[str, Any]) -> ToolResult:
99
+ if self._tool_idx >= len(self.tool_results):
100
+ raise RuntimeError(
101
+ f"AUDIT replay divergence: tool call #{self._tool_idx} "
102
+ f"({ref.name}) not in recording "
103
+ f"(only {len(self.tool_results)} recorded)"
104
+ )
105
+ _name, _args, result = self.tool_results[self._tool_idx]
106
+ self._tool_idx += 1
107
+ return result
108
+
109
+ def retrieve(self, query: str, top_k: int = 5) -> list[Snippet]:
110
+ if self._retrieval_idx >= len(self.retrievals):
111
+ return []
112
+ _query, snippets = self.retrievals[self._retrieval_idx]
113
+ self._retrieval_idx += 1
114
+ return snippets
115
+
116
+ def query_model(self, request: ModelRequest) -> ModelResponse:
117
+ if self._model_idx >= len(self.model_responses):
118
+ raise RuntimeError(
119
+ f"AUDIT replay divergence: model call #{self._model_idx} "
120
+ f"not in recording "
121
+ f"(only {len(self.model_responses)} recorded)"
122
+ )
123
+ response = self.model_responses[self._model_idx]
124
+ self._model_idx += 1
125
+ return response
126
+
127
+
128
+ # ---------------------------------------------------------------------------
129
+ # ResumableEnvironment — hybrid: recorded up to checkpoint, then fresh
130
+ # ---------------------------------------------------------------------------
131
+
132
+ class ResumableEnvironment:
133
+ """Serves recorded responses up to a checkpoint, then delegates to live env.
134
+
135
+ For RESUME replay: re-execute the deterministic prefix, then continue
136
+ with fresh model calls from the checkpoint onward. This enables
137
+ failure recovery — the agent re-does the checkpoint-to-failure segment
138
+ with potentially different (and hopefully successful) model outputs.
139
+ """
140
+
141
+ def __init__(
142
+ self,
143
+ recording: RecordingEnvironment,
144
+ live_env: Environment,
145
+ checkpoint_model_idx: int,
146
+ checkpoint_tool_idx: int,
147
+ ) -> None:
148
+ self._recording = recording
149
+ self._live = live_env
150
+ self._cp_model = checkpoint_model_idx
151
+ self._cp_tool = checkpoint_tool_idx
152
+ self._model_idx = 0
153
+ self._tool_idx = 0
154
+ self._switched = False
155
+
156
+ def tools(self) -> list[Tool]:
157
+ return self._live.tools()
158
+
159
+ def call(self, ref: ToolRef, args: dict[str, Any]) -> ToolResult:
160
+ if not self._switched and self._tool_idx < self._cp_tool:
161
+ _name, _args, result = self._recording.tool_calls[self._tool_idx]
162
+ self._tool_idx += 1
163
+ return result
164
+ self._switched = True
165
+ return self._live.call(ref, args)
166
+
167
+ def retrieve(self, query: str, top_k: int = 5) -> list[Snippet]:
168
+ return self._live.retrieve(query, top_k)
169
+
170
+ def query_model(self, request: ModelRequest) -> ModelResponse:
171
+ if not self._switched and self._model_idx < self._cp_model:
172
+ response = self._recording.model_responses[self._model_idx]
173
+ self._model_idx += 1
174
+ return response
175
+ self._switched = True
176
+ return self._live.query_model(request)
177
+
178
+
179
+ # ---------------------------------------------------------------------------
180
+ # Convenience: build ReplayEnvironment from a RecordingEnvironment
181
+ # ---------------------------------------------------------------------------
182
+
183
+ def replay_environment_from_recording(recording: RecordingEnvironment) -> ReplayEnvironment:
184
+ """Create a ReplayEnvironment (AUDIT mode) from a recording."""
185
+ return ReplayEnvironment(
186
+ model_responses=list(recording.model_responses),
187
+ tool_results=list(recording.tool_calls),
188
+ retrievals=list(recording.retrievals),
189
+ _tools=recording.tools(),
190
+ )
@@ -0,0 +1,224 @@
1
+ """Retry infrastructure for transient-failure resilience.
2
+
3
+ Wraps any ModelAdapter with configurable exponential-backoff retry.
4
+ Designed as a pure wrapper so no existing core/ source needs modification.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ import random
10
+ import time
11
+ from dataclasses import dataclass, field
12
+ from typing import Awaitable, Callable, TypeVar
13
+
14
+ from petfishframework.core.contracts import ModelAdapter
15
+ from petfishframework.core.types import ModelRequest, ModelResponse
16
+
17
+
18
+ class RetryableError(Exception):
19
+ """Raised when all retry attempts are exhausted.
20
+
21
+ Carries the original exception, the number of attempts made, and the
22
+ total elapsed time so callers can diagnose the failure.
23
+ """
24
+
25
+ def __init__(
26
+ self,
27
+ message: str,
28
+ *,
29
+ original: Exception,
30
+ attempts: int,
31
+ elapsed_s: float,
32
+ ):
33
+ super().__init__(message)
34
+ self.original = original
35
+ self.attempts = attempts
36
+ self.elapsed_s = elapsed_s
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class RetryPolicy:
41
+ """Configuration for retry/backoff behavior."""
42
+
43
+ max_retries: int = 3
44
+ initial_delay: float = 1.0
45
+ backoff_factor: float = 2.0
46
+ retryable_exceptions: tuple[type[Exception], ...] = (Exception,)
47
+ jitter: bool = True
48
+ max_delay: float = 60.0
49
+
50
+ def delay_for_attempt(self, attempt: int) -> float:
51
+ """Compute the backoff delay before attempt number ``attempt``.
52
+
53
+ ``attempt`` is 0-indexed: the first retry uses ``attempt=0``.
54
+ """
55
+ base = self.initial_delay * (self.backoff_factor**attempt)
56
+ capped = min(base, self.max_delay)
57
+ if not self.jitter:
58
+ return capped
59
+ # Add/subtract up to 25% jitter, but never go below zero.
60
+ jitter_amount = capped * 0.25
61
+ return max(0.0, capped + random.uniform(-jitter_amount, jitter_amount))
62
+
63
+
64
+ T = TypeVar("T")
65
+
66
+
67
+ def with_retry(
68
+ fn: Callable[[], T],
69
+ policy: RetryPolicy,
70
+ attempts_log: list[Exception] | None = None,
71
+ ) -> Callable[[], T]:
72
+ """Wrap a synchronous callable with retry logic.
73
+
74
+ The wrapper re-invokes ``fn`` up to ``policy.max_retries`` times when
75
+ ``policy.retryable_exceptions`` are raised. Between attempts it sleeps
76
+ using ``time.sleep`` with exponential backoff and optional jitter.
77
+
78
+ If ``attempts_log`` is supplied, every retryable exception that triggered
79
+ a backoff is appended to it, enabling callers to count retries.
80
+ """
81
+
82
+ def wrapper() -> T:
83
+ start = time.monotonic()
84
+ last_error: Exception | None = None
85
+ for attempt in range(policy.max_retries + 1):
86
+ try:
87
+ return fn()
88
+ except Exception as exc: # noqa: BLE001
89
+ last_error = exc
90
+ if not isinstance(exc, policy.retryable_exceptions):
91
+ raise
92
+ if attempt >= policy.max_retries:
93
+ break
94
+ if attempts_log is not None:
95
+ attempts_log.append(exc)
96
+ time.sleep(policy.delay_for_attempt(attempt))
97
+ elapsed = time.monotonic() - start
98
+ assert last_error is not None
99
+ raise RetryableError(
100
+ f"Failed after {policy.max_retries + 1} attempt(s): {last_error}",
101
+ original=last_error,
102
+ attempts=policy.max_retries + 1,
103
+ elapsed_s=elapsed,
104
+ )
105
+
106
+ return wrapper
107
+
108
+
109
+ def with_retry_async(
110
+ fn: Callable[[], Awaitable[T]],
111
+ policy: RetryPolicy,
112
+ attempts_log: list[Exception] | None = None,
113
+ ) -> Callable[[], Awaitable[T]]:
114
+ """Wrap an asynchronous callable with retry logic.
115
+
116
+ Identical semantics to ``with_retry`` but uses ``asyncio.sleep``.
117
+ """
118
+
119
+ async def wrapper() -> T:
120
+ start = time.monotonic()
121
+ last_error: Exception | None = None
122
+ for attempt in range(policy.max_retries + 1):
123
+ try:
124
+ return await fn()
125
+ except Exception as exc: # noqa: BLE001
126
+ last_error = exc
127
+ if not isinstance(exc, policy.retryable_exceptions):
128
+ raise
129
+ if attempt >= policy.max_retries:
130
+ break
131
+ if attempts_log is not None:
132
+ attempts_log.append(exc)
133
+ await asyncio.sleep(policy.delay_for_attempt(attempt))
134
+ elapsed = time.monotonic() - start
135
+ assert last_error is not None
136
+ raise RetryableError(
137
+ f"Failed after {policy.max_retries + 1} attempt(s): {last_error}",
138
+ original=last_error,
139
+ attempts=policy.max_retries + 1,
140
+ elapsed_s=elapsed,
141
+ )
142
+
143
+ return wrapper
144
+
145
+
146
+ @dataclass
147
+ class RetryModelAdapter(ModelAdapter):
148
+ """A ModelAdapter wrapper that retries transient failures.
149
+
150
+ Delegates ``name`` to the inner adapter and wraps ``query`` (and
151
+ ``query_async`` when the inner adapter supports it) with retry logic.
152
+ """
153
+
154
+ inner: ModelAdapter
155
+ policy: RetryPolicy = field(default_factory=RetryPolicy)
156
+
157
+ retry_count: int = field(default=0, init=False, repr=False)
158
+ last_error: Exception | None = field(default=None, init=False, repr=False)
159
+ name: str = field(default="", init=False, repr=False)
160
+
161
+ def __post_init__(self) -> None:
162
+ self.name = self.inner.name
163
+
164
+ def query(self, request: ModelRequest) -> ModelResponse:
165
+ """Query the inner model, retrying according to policy."""
166
+ self.retry_count = 0
167
+ self.last_error = None
168
+ attempts: list[Exception] = []
169
+
170
+ def _call() -> ModelResponse:
171
+ return self.inner.query(request)
172
+
173
+ try:
174
+ response = with_retry(_call, self.policy, attempts)()
175
+ except RetryableError as exc:
176
+ self.retry_count = exc.attempts - 1
177
+ self.last_error = exc.original
178
+ raise
179
+
180
+ self.retry_count = len(attempts)
181
+ if attempts:
182
+ self.last_error = attempts[-1]
183
+ return response
184
+
185
+ def query_async(self, request: ModelRequest) -> Awaitable[ModelResponse]:
186
+ """Async query the inner model, retrying according to policy.
187
+
188
+ Detects an async inner ``query`` via ``asyncio.iscoroutinefunction``
189
+ (consistent with RuntimeEnvironment) and awaits it; otherwise the
190
+ synchronous ``query`` is run inside the async retry loop.
191
+ """
192
+ self.retry_count = 0
193
+ self.last_error = None
194
+ attempts: list[Exception] = []
195
+
196
+ async def _async_call() -> ModelResponse:
197
+ if asyncio.iscoroutinefunction(self.inner.query):
198
+ return await self.inner.query(request)
199
+ return self.inner.query(request)
200
+
201
+ async def _run() -> ModelResponse:
202
+ try:
203
+ response = await with_retry_async(_async_call, self.policy, attempts)()
204
+ except RetryableError as exc:
205
+ self.retry_count = exc.attempts - 1
206
+ self.last_error = exc.original
207
+ raise
208
+
209
+ self.retry_count = len(attempts)
210
+ if attempts:
211
+ self.last_error = attempts[-1]
212
+ return response
213
+
214
+ return _run()
215
+
216
+ def reset_stats(self) -> None:
217
+ """Reset retry statistics."""
218
+ self.retry_count = 0
219
+ self.last_error = None
220
+
221
+
222
+ def retry_model_adapter(model: ModelAdapter, policy: RetryPolicy | None = None) -> RetryModelAdapter:
223
+ """Convenience factory for wrapping a model adapter with retries."""
224
+ return RetryModelAdapter(inner=model, policy=policy or RetryPolicy())
@@ -0,0 +1,55 @@
1
+ """Operation timeout utilities (M4 gap).
2
+
3
+ Provides a small timeout policy object and a thread-pool based wrapper for
4
+ applying a hard wall-clock timeout to synchronous callables. Intended as a
5
+ building block for model-call, tool-call, and retrieval timeouts without
6
+ modifying callers' interfaces.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from concurrent.futures import ThreadPoolExecutor
11
+ from dataclasses import dataclass
12
+ from typing import Callable, ParamSpec, TypeVar
13
+
14
+ T = TypeVar("T")
15
+ P = ParamSpec("P")
16
+
17
+
18
+ class OperationTimedOut(Exception):
19
+ """Raised when an operation exceeds its configured timeout."""
20
+
21
+ def __init__(self, operation: str, timeout_s: float):
22
+ self.operation = operation
23
+ self.timeout_s = timeout_s
24
+ super().__init__(f"Operation '{operation}' timed out after {timeout_s}s")
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class TimeoutPolicy:
29
+ """Timeout defaults for different operation categories."""
30
+
31
+ model_call_timeout_s: float = 60.0
32
+ tool_call_timeout_s: float = 30.0
33
+ retrieval_timeout_s: float = 10.0
34
+
35
+
36
+ def with_timeout(fn: Callable[P, T], timeout_s: float) -> Callable[P, T]:
37
+ """Wrap a synchronous callable with a hard timeout.
38
+
39
+ The callable runs in a single worker thread from a temporary
40
+ ``ThreadPoolExecutor``. If it does not complete within ``timeout_s``
41
+ seconds, a ``concurrent.futures.TimeoutError`` is translated into a
42
+ framework-specific ``OperationTimedOut`` error carrying the operation name
43
+ and timeout value.
44
+ """
45
+
46
+ def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
47
+ with ThreadPoolExecutor(max_workers=1) as executor:
48
+ future = executor.submit(fn, *args, **kwargs)
49
+ try:
50
+ return future.result(timeout=timeout_s)
51
+ except TimeoutError as exc:
52
+ operation = getattr(fn, "__name__", repr(fn))
53
+ raise OperationTimedOut(operation=operation, timeout_s=timeout_s) from exc
54
+
55
+ return wrapper