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.
- petfishframework/__init__.py +33 -0
- petfishframework/config.py +142 -0
- petfishframework/core/__init__.py +91 -0
- petfishframework/core/agent.py +207 -0
- petfishframework/core/compiled.py +89 -0
- petfishframework/core/contracts.py +190 -0
- petfishframework/core/conversation.py +51 -0
- petfishframework/core/environment.py +260 -0
- petfishframework/core/events.py +79 -0
- petfishframework/core/session.py +182 -0
- petfishframework/core/structured.py +202 -0
- petfishframework/core/types.py +192 -0
- petfishframework/mcp/__init__.py +19 -0
- petfishframework/mcp/client.py +96 -0
- petfishframework/mcp/server.py +14 -0
- petfishframework/mcp/stdio_transport.py +218 -0
- petfishframework/mcp/wrapper.py +43 -0
- petfishframework/models/__init__.py +14 -0
- petfishframework/models/anthropic.py +194 -0
- petfishframework/models/fake.py +202 -0
- petfishframework/models/openai.py +178 -0
- petfishframework/observability/__init__.py +10 -0
- petfishframework/observability/sinks.py +23 -0
- petfishframework/permissions/__init__.py +32 -0
- petfishframework/permissions/model.py +110 -0
- petfishframework/reasoning/__init__.py +13 -0
- petfishframework/reasoning/lats.py +222 -0
- petfishframework/reasoning/llm_plus_p.py +202 -0
- petfishframework/reasoning/react.py +176 -0
- petfishframework/reliability/__init__.py +78 -0
- petfishframework/reliability/cost.py +50 -0
- petfishframework/reliability/cost_report.py +101 -0
- petfishframework/reliability/pass_at_k.py +232 -0
- petfishframework/reliability/replay.py +190 -0
- petfishframework/reliability/retry.py +224 -0
- petfishframework/reliability/timeout.py +55 -0
- petfishframework/retrieval/__init__.py +12 -0
- petfishframework/retrieval/adaptive.py +137 -0
- petfishframework/retrieval/crag.py +135 -0
- petfishframework/retrieval/memory_store.py +72 -0
- petfishframework/tools/__init__.py +12 -0
- petfishframework/tools/agent_tool.py +47 -0
- petfishframework/tools/base.py +75 -0
- petfishframework/tools/calculator.py +84 -0
- petfishframework/tools/path_planner.py +90 -0
- petfishframework/tools/registry.py +158 -0
- petfishframework/tools/word_sorter.py +41 -0
- petfishframework-0.1.0.dist-info/METADATA +151 -0
- petfishframework-0.1.0.dist-info/RECORD +51 -0
- petfishframework-0.1.0.dist-info/WHEEL +4 -0
- 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
|