bantamkit 0.27.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.
- bantamkit/__init__.py +32 -0
- bantamkit/agent.py +458 -0
- bantamkit/assets/contracts/default.yaml +90 -0
- bantamkit/assets/evals/devteam/manifest.yaml +351 -0
- bantamkit/assets/evals/devteam/repo/HISTORY.md +18 -0
- bantamkit/assets/evals/devteam/repo/README.md +12 -0
- bantamkit/assets/evals/devteam/repo/docs/architecture.md +17 -0
- bantamkit/assets/evals/devteam/repo/docs/runbook.md +10 -0
- bantamkit/assets/evals/devteam/repo/issues/142-settlement-timeout.md +23 -0
- bantamkit/assets/evals/devteam/repo/patches/0009-retry-budget.patch +38 -0
- bantamkit/assets/evals/devteam/repo/src/ledger/__init__.py +3 -0
- bantamkit/assets/evals/devteam/repo/src/ledger/config.py +35 -0
- bantamkit/assets/evals/devteam/repo/src/ledger/errors.py +13 -0
- bantamkit/assets/evals/devteam/repo/src/ledger/posting.py +12 -0
- bantamkit/assets/evals/devteam/repo/src/ledger/registry.py +7 -0
- bantamkit/assets/evals/devteam/repo/src/ledger/report.py +9 -0
- bantamkit/assets/evals/devteam/repo/src/ledger/retry.py +17 -0
- bantamkit/assets/evals/devteam/repo/src/ledger/settle.py +16 -0
- bantamkit/assets/evals/devteam/repo/src/ledger/validate.py +14 -0
- bantamkit/assets/evals/devteam/repo/tests/test_posting.py +13 -0
- bantamkit/assets/evals/devteam/repo/tests/test_settle.py +9 -0
- bantamkit/assets/evals/devteam/tasks/dt-error-contract.yaml +186 -0
- bantamkit/assets/evals/devteam/tasks/dt-handler-map.yaml +183 -0
- bantamkit/assets/evals/devteam/tasks/dt-patch-before-after.yaml +182 -0
- bantamkit/assets/evals/devteam/tasks/dt-retry-attempts.yaml +181 -0
- bantamkit/assets/evals/devteam/tasks/dt-settlement-config.yaml +185 -0
- bantamkit/assets/evals/devteam/tasks/dt-symbol-home.yaml +181 -0
- bantamkit/assets/evals/devteam/tasks/dt-trace-blame.yaml +182 -0
- bantamkit/assets/evals/devteam/tasks/dt-unread-key.yaml +180 -0
- bantamkit/assets/evals/document/tasks/doc-large-in-137.yaml +38 -0
- bantamkit/assets/evals/document/tasks/doc-large-in-359.yaml +44 -0
- bantamkit/assets/evals/document/tasks/doc-large-in-372.yaml +38 -0
- bantamkit/assets/evals/document/tasks/doc-large-out-11764.yaml +37 -0
- bantamkit/assets/evals/document/tasks/doc-large-out-4137.yaml +37 -0
- bantamkit/assets/evals/document/tasks/doc-large-out-8022.yaml +37 -0
- bantamkit/assets/evals/document/tasks/doc-small-137.yaml +37 -0
- bantamkit/assets/evals/document/tasks/doc-small-261.yaml +37 -0
- bantamkit/assets/evals/document/tasks/doc-small-388.yaml +37 -0
- bantamkit/assets/evals/fixtures/.gitkeep +0 -0
- bantamkit/assets/evals/fixtures/catalog.json +6 -0
- bantamkit/assets/evals/perturbations/task-completion.yaml +576 -0
- bantamkit/assets/evals/tasks/.gitkeep +0 -0
- bantamkit/assets/evals/tasks/extract-contact.yaml +14 -0
- bantamkit/assets/evals/tasks/extract-invoice.yaml +14 -0
- bantamkit/assets/evals/tasks/extract-order.yaml +15 -0
- bantamkit/assets/evals/tasks/extract-schedule.yaml +14 -0
- bantamkit/assets/evals/tasks/extract-versions.yaml +17 -0
- bantamkit/assets/evals/tasks/nav-prod-port.yaml +84 -0
- bantamkit/assets/evals/tasks/nav-release-bundle.yaml +87 -0
- bantamkit/assets/evals/tasks/recall-audit-retention.yaml +17 -0
- bantamkit/assets/evals/tasks/recall-cache-ttl.yaml +13 -0
- bantamkit/assets/evals/tasks/recall-db-port.yaml +17 -0
- bantamkit/assets/evals/tasks/recall-deploy.yaml +13 -0
- bantamkit/assets/evals/tasks/recall-env-endpoint.yaml +18 -0
- bantamkit/assets/evals/tasks/recall-oncall-rotation.yaml +21 -0
- bantamkit/assets/evals/tasks/recall-oncall.yaml +13 -0
- bantamkit/assets/evals/tasks/recall-org-quota.yaml +18 -0
- bantamkit/assets/evals/tasks/recall-owner.yaml +13 -0
- bantamkit/assets/evals/tasks/shop-basket-total.yaml +10 -0
- bantamkit/assets/evals/tasks/shop-cheapest.yaml +9 -0
- bantamkit/assets/evals/tasks/shop-compare.yaml +9 -0
- bantamkit/assets/evals/tasks/shop-gadget-value.yaml +9 -0
- bantamkit/assets/evals/tasks/shop-stock-total.yaml +9 -0
- bantamkit/assets/evals/tasks/shop-total.yaml +9 -0
- bantamkit/assets/profiles/default.yaml +31 -0
- bantamkit/assets/profiles/patient.yaml +31 -0
- bantamkit/assets/rubrics/.gitkeep +0 -0
- bantamkit/assets/rubrics/code-quality.yaml +20 -0
- bantamkit/assets/rubrics/grounded-completion.yaml +37 -0
- bantamkit/assets/rubrics/task-completion.yaml +28 -0
- bantamkit/assets/schemas/shiftwork-checkpoint.json +188 -0
- bantamkit/assets/skills/.gitkeep +0 -0
- bantamkit/assets/skills/file-graph.md +7 -0
- bantamkit/assets/skills/memory.md +35 -0
- bantamkit/assets/tools/.gitkeep +0 -0
- bantamkit/assets/tools/bantamkit_read.json +48 -0
- bantamkit/assets/tools/bantamkit_status.json +25 -0
- bantamkit/assets/tools/build_identity.json +17 -0
- bantamkit/assets/tools/document_list.json +12 -0
- bantamkit/assets/tools/document_read.json +31 -0
- bantamkit/assets/tools/file_graph.json +12 -0
- bantamkit/assets/tools/memory_compact.json +31 -0
- bantamkit/assets/tools/memory_recall.json +38 -0
- bantamkit/assets/tools/memory_save.json +61 -0
- bantamkit/assets/tools/shiftwork_clock_in.json +25 -0
- bantamkit/assets/tools/shiftwork_clock_out.json +60 -0
- bantamkit/assets/tools/shiftwork_status.json +25 -0
- bantamkit/assets/tools/skill_audit.json +70 -0
- bantamkit/assets/tools/validate_json.json +31 -0
- bantamkit/assets.py +67 -0
- bantamkit/budget.py +114 -0
- bantamkit/client.py +329 -0
- bantamkit/contract.py +522 -0
- bantamkit/criticreplay.py +3241 -0
- bantamkit/critique.py +301 -0
- bantamkit/docread.py +1744 -0
- bantamkit/evalrun.py +2003 -0
- bantamkit/eventlog.py +282 -0
- bantamkit/filegraph.py +218 -0
- bantamkit/loopguard.py +101 -0
- bantamkit/mcpreport.py +763 -0
- bantamkit/mcpserver.py +1334 -0
- bantamkit/memory/__init__.py +28 -0
- bantamkit/memory/__main__.py +291 -0
- bantamkit/memory/component.py +569 -0
- bantamkit/memory/divergence.py +744 -0
- bantamkit/memory/layers.py +257 -0
- bantamkit/memory/store.py +940 -0
- bantamkit/pdfread.py +1402 -0
- bantamkit/profile.py +46 -0
- bantamkit/shiftwork.py +212 -0
- bantamkit/skillaudit.py +853 -0
- bantamkit/statusline.py +313 -0
- bantamkit/structured.py +125 -0
- bantamkit/textutil.py +30 -0
- bantamkit-0.27.0.dist-info/METADATA +207 -0
- bantamkit-0.27.0.dist-info/RECORD +119 -0
- bantamkit-0.27.0.dist-info/WHEEL +4 -0
- bantamkit-0.27.0.dist-info/entry_points.txt +2 -0
bantamkit/critique.py
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
"""Score-and-retry gate: rubric is data, scoring is schema-enforced, rounds are bounded."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
|
|
9
|
+
from bantamkit.agent import Agent
|
|
10
|
+
from bantamkit.assets import AssetNotFound, assets_root
|
|
11
|
+
from bantamkit.client import BantamError, Message, ModelClient
|
|
12
|
+
from bantamkit.contract import critique_feedback as _critique_feedback
|
|
13
|
+
from bantamkit.contract import render_evidence
|
|
14
|
+
from bantamkit.profile import default as profile_default
|
|
15
|
+
from bantamkit.structured import structured
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"CritiqueExhausted",
|
|
19
|
+
"CritiqueGate",
|
|
20
|
+
"GroundedCritiqueGate",
|
|
21
|
+
"Rubric",
|
|
22
|
+
"load_rubric",
|
|
23
|
+
"render_evidence",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class CritiqueExhausted(BantamError):
|
|
28
|
+
"""Output stayed below threshold for max_rounds critiques.
|
|
29
|
+
|
|
30
|
+
Carries the transcript up to the raise, same payload and same reason as
|
|
31
|
+
`MaxTurnsExceeded` and `StructuredOutputError`: the gate raises from inside
|
|
32
|
+
the agent's post-hook chain, so without a slot every `critique-exhausted`
|
|
33
|
+
run recorded `messages: []` and RP2 had to monkeypatch the runtime to read
|
|
34
|
+
one. `Agent.run` fills the slot in — the gate itself is handed only the
|
|
35
|
+
task and the answer.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, message: str, messages: list[Message] | None = None):
|
|
39
|
+
super().__init__(message)
|
|
40
|
+
self.messages: list[Message] = list(messages or [])
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _validate_rubric(rubric: Rubric) -> None:
|
|
44
|
+
"""Validate that rubric prompt contains required placeholders.
|
|
45
|
+
|
|
46
|
+
Raises BantamError if {task} or {output} are missing.
|
|
47
|
+
"""
|
|
48
|
+
missing = []
|
|
49
|
+
if "{task}" not in rubric.prompt:
|
|
50
|
+
missing.append("task")
|
|
51
|
+
if "{output}" not in rubric.prompt:
|
|
52
|
+
missing.append("output")
|
|
53
|
+
if missing:
|
|
54
|
+
raise BantamError(
|
|
55
|
+
f"rubric '{rubric.name}' prompt missing placeholder(s): "
|
|
56
|
+
f"{', '.join('{' + p + '}' for p in missing)}"
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _validate_grounded_rubric(rubric: Rubric) -> None:
|
|
61
|
+
_validate_rubric(rubric)
|
|
62
|
+
if "{evidence}" not in rubric.prompt:
|
|
63
|
+
raise BantamError(f"rubric '{rubric.name}' prompt missing placeholder(s): {{evidence}}")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _sampling_is_pinned(client: object) -> bool:
|
|
67
|
+
"""Whether the critic's own calls carry a pinned sampling seed, seen through wrappers.
|
|
68
|
+
|
|
69
|
+
Exactly that and no more: a seed is *set*, which is not the same claim as sampling
|
|
70
|
+
being deterministic. Whether the backend honours the pin is not visible from here —
|
|
71
|
+
that half is the caller's to affirm (`CritiqueGate(deterministic_sampling=...)`).
|
|
72
|
+
|
|
73
|
+
Duck-typed, the same spirit as `agent._supports_response_format` and as the eval
|
|
74
|
+
harness's own `hasattr(client, "seed")`: `OpenAICompatible` carries a `seed` and
|
|
75
|
+
the harness pins it per run, while a client that has none is free to resample.
|
|
76
|
+
|
|
77
|
+
The walk down `.inner` is not decoration. A gate inherits `agent.client`, which in
|
|
78
|
+
the eval harness is `TrackingClient` — no seed of its own and, unlike
|
|
79
|
+
`_BudgetedClient`, no attribute passthrough — wrapped around the adapter that
|
|
80
|
+
actually holds the seed. Reading only the outermost object would report every
|
|
81
|
+
seeded eval run as unseeded.
|
|
82
|
+
"""
|
|
83
|
+
seen: set[int] = set()
|
|
84
|
+
while client is not None and id(client) not in seen:
|
|
85
|
+
seen.add(id(client))
|
|
86
|
+
if getattr(client, "seed", None) is not None:
|
|
87
|
+
return True
|
|
88
|
+
client = getattr(client, "inner", None)
|
|
89
|
+
return False
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass
|
|
93
|
+
class Rubric:
|
|
94
|
+
name: str
|
|
95
|
+
threshold: int
|
|
96
|
+
prompt: str # must contain {task} and {output}
|
|
97
|
+
schema: dict
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def load_rubric(name: str) -> Rubric:
|
|
101
|
+
path = assets_root() / "rubrics" / f"{name}.yaml"
|
|
102
|
+
if not path.exists():
|
|
103
|
+
raise AssetNotFound(f"rubric asset not found: {path}")
|
|
104
|
+
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
105
|
+
rubric = Rubric(
|
|
106
|
+
name=data["name"],
|
|
107
|
+
threshold=int(data["threshold"]),
|
|
108
|
+
prompt=data["prompt"],
|
|
109
|
+
schema=data["schema"],
|
|
110
|
+
)
|
|
111
|
+
_validate_rubric(rubric)
|
|
112
|
+
return rubric
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class CritiqueGate:
|
|
116
|
+
def __init__(
|
|
117
|
+
self,
|
|
118
|
+
rubric: str | Rubric,
|
|
119
|
+
client: ModelClient | None = None,
|
|
120
|
+
max_rounds: int | None = None,
|
|
121
|
+
deterministic_sampling: bool = False,
|
|
122
|
+
):
|
|
123
|
+
"""`deterministic_sampling` affirms that this critic's backend reproduces a
|
|
124
|
+
verdict's *decision* — its score — for a fixed request and a pinned seed.
|
|
125
|
+
|
|
126
|
+
Narrowed to that, and no wider, because that is what is measured (RB-P15).
|
|
127
|
+
Across 30 replay requests over six cells the score never moved within a cell;
|
|
128
|
+
the verdict *text* did, at an identical request payload sha, both inside one
|
|
129
|
+
process and across five. So the old wording — "reproduces a sample exactly" —
|
|
130
|
+
claimed byte stability this backend does not deliver. The score is the only
|
|
131
|
+
thing the gate branches on, so the narrower claim is also the only one the memo
|
|
132
|
+
needs.
|
|
133
|
+
|
|
134
|
+
It is off by default and it is the caller's claim to make, because the library
|
|
135
|
+
cannot check it: one adapter covers Ollama, vLLM, llama.cpp and OpenRouter, and
|
|
136
|
+
on the batching ones a seed is best-effort. See `_verdict` for what the
|
|
137
|
+
affirmation buys.
|
|
138
|
+
"""
|
|
139
|
+
if isinstance(rubric, Rubric):
|
|
140
|
+
_validate_rubric(rubric)
|
|
141
|
+
self.rubric = rubric
|
|
142
|
+
else:
|
|
143
|
+
self.rubric = load_rubric(rubric)
|
|
144
|
+
self.client = client
|
|
145
|
+
self.deterministic_sampling = deterministic_sampling
|
|
146
|
+
self.max_rounds = (
|
|
147
|
+
max_rounds if max_rounds is not None else profile_default("critique", "max_rounds")
|
|
148
|
+
)
|
|
149
|
+
self._rounds = 0
|
|
150
|
+
self.rounds_used = 0
|
|
151
|
+
self.budget = None
|
|
152
|
+
# Per-run: the last critic prompt and the verdict it bought. See `_verdict`.
|
|
153
|
+
self._last_prompt: str | None = None
|
|
154
|
+
self._last_verdict: dict | None = None
|
|
155
|
+
|
|
156
|
+
def setup(self, agent: Agent) -> None:
|
|
157
|
+
if self.client is None:
|
|
158
|
+
self.client = agent.client
|
|
159
|
+
self.rounds_used = 0
|
|
160
|
+
# The consecutive-below-threshold streak, and the one piece of per-run state
|
|
161
|
+
# this reset used to miss. It is zeroed by a passing verdict or by the raise,
|
|
162
|
+
# neither of which happens to a run whose turn budget expires while the gate is
|
|
163
|
+
# still feeding back — so on a reused instance the next run started part-way to
|
|
164
|
+
# exhaustion and could raise "after N rounds" having spent one.
|
|
165
|
+
self._rounds = 0
|
|
166
|
+
self._last_prompt = None
|
|
167
|
+
self._last_verdict = None
|
|
168
|
+
# Duck-typed and optional: a run with no governor keeps every round it had.
|
|
169
|
+
# Attach the budget before this gate, or the handle is None for the whole
|
|
170
|
+
# run — and the client captured above is the unwrapped one, so critic
|
|
171
|
+
# spend never reaches the governor either. Budget first, always.
|
|
172
|
+
self.budget = getattr(agent, "budget", None)
|
|
173
|
+
agent.add_post_hook(self)
|
|
174
|
+
|
|
175
|
+
def __call__(self, task: str, output: str) -> str | None:
|
|
176
|
+
return self._judge(task=task, output=output)
|
|
177
|
+
|
|
178
|
+
def _judge(self, **fields: str) -> str | None:
|
|
179
|
+
# A critique round is optional work (see `TokenBudget`): it is the largest
|
|
180
|
+
# per-answer cost and the run is still correct without it. Denied means
|
|
181
|
+
# accept the answer, never an exception — and the round counters are left
|
|
182
|
+
# alone, because a round that was never spent is not a round.
|
|
183
|
+
if self.budget is not None and not self.budget.allow("optional"):
|
|
184
|
+
return None
|
|
185
|
+
verdict = self._verdict(self.rubric.prompt.format(**fields))
|
|
186
|
+
if verdict["score"] >= self.rubric.threshold:
|
|
187
|
+
self._rounds = 0
|
|
188
|
+
return None
|
|
189
|
+
self._rounds += 1
|
|
190
|
+
# Counted before the exhaustion check, not after: the round that raises is a
|
|
191
|
+
# round that was spent. Counting it afterwards meant every exhausted run
|
|
192
|
+
# recorded one round fewer than the critic calls its own error text quoted
|
|
193
|
+
# ("after 3 rounds" beside a recorded 2), and per-round token attribution
|
|
194
|
+
# computed from the column came out a third too high. `rounds_used` is
|
|
195
|
+
# "critique rounds that judged the answer below threshold" — a round the
|
|
196
|
+
# budget denied is still not counted, because it was never spent.
|
|
197
|
+
self.rounds_used += 1
|
|
198
|
+
if self._rounds >= self.max_rounds:
|
|
199
|
+
self._rounds = 0
|
|
200
|
+
raise CritiqueExhausted(
|
|
201
|
+
f"below threshold {self.rubric.threshold} after {self.max_rounds} rounds; "
|
|
202
|
+
f"last feedback: {verdict['feedback']}"
|
|
203
|
+
)
|
|
204
|
+
return _critique_feedback(
|
|
205
|
+
score=verdict["score"],
|
|
206
|
+
threshold=self.rubric.threshold,
|
|
207
|
+
feedback=verdict["feedback"],
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
def _verdict(self, prompt: str) -> dict:
|
|
211
|
+
"""The critic's judgement of this prompt — bought once per distinct prompt.
|
|
212
|
+
|
|
213
|
+
Where sampling is deterministic, the critic's *decision* is a deterministic
|
|
214
|
+
function of its prompt, so a round that re-judges an unchanged answer is spend
|
|
215
|
+
for a verdict already in hand. RP2 measured the shape: on the 14b
|
|
216
|
+
`nav-prod-port` cell every round judged the identical `{"port": 9443}` and got
|
|
217
|
+
back the identical score, and each of those rounds was a provably unwinnable
|
|
218
|
+
one, because an unchanged answer guarantees an unchanged score guarantees
|
|
219
|
+
exhaustion.
|
|
220
|
+
|
|
221
|
+
Reuse, never a short cut around the loop. The reused verdict runs the same
|
|
222
|
+
counting and the same exhaustion check the paid one would have, so
|
|
223
|
+
`rounds_used` and the raised error come out exactly as they did — one model call
|
|
224
|
+
fewer is the entire difference. The *feedback bytes* are one of the samples the
|
|
225
|
+
backend would have produced at that score, not necessarily the one this round
|
|
226
|
+
would have drawn (RB-P15): verdict text is not byte-stable across processes.
|
|
227
|
+
Nothing branches on those bytes; they are relayed to the answerer, and the
|
|
228
|
+
alternative — paying for a call whose decision is already known — buys a
|
|
229
|
+
different wording, not a different outcome. Stopping the loop early
|
|
230
|
+
instead would be the cheaper fix and the wrong one: the answerer sees a longer
|
|
231
|
+
conversation each round and may still change its answer on the round after a
|
|
232
|
+
repeat, so raising there could turn a run that would have passed into a loss.
|
|
233
|
+
|
|
234
|
+
The key is the rendered prompt's exact bytes, which is the literal input the
|
|
235
|
+
critic would have received — for `GroundedCritiqueGate` that includes the
|
|
236
|
+
evidence, so a new tool observation is correctly a new question even under an
|
|
237
|
+
unchanged answer. An answer that changed *textually* is likewise a new question
|
|
238
|
+
and is paid for, even when it means the same thing as the last one (RP4d: the
|
|
239
|
+
answerer sometimes appeases the critic by re-emitting the same value
|
|
240
|
+
pretty-printed). Byte equality is the only equality this layer can prove;
|
|
241
|
+
semantic equality is a judgement, and judging is the critic's job.
|
|
242
|
+
|
|
243
|
+
Off by default, and on only when both halves of "the verdict is predictable"
|
|
244
|
+
hold. That rule — this may only skip a call whose result it can predict, never
|
|
245
|
+
one it merely expects — is the whole justification, and one half of it is not
|
|
246
|
+
checkable from inside the library:
|
|
247
|
+
|
|
248
|
+
- the caller affirmed `deterministic_sampling`, i.e. this backend reproduces a
|
|
249
|
+
verdict's decision — its score, not its bytes — for a fixed request and a
|
|
250
|
+
pinned seed. Ollama does at the score level; `client.py`
|
|
251
|
+
covers vLLM and OpenRouter with the same adapter, and there a seed is
|
|
252
|
+
best-effort (continuous batching, upstream fingerprint drift). A seed being
|
|
253
|
+
*set* proves nothing about that, so the affirmation cannot be inferred — it
|
|
254
|
+
has to be stated, and stating it wrongly is the one way to lose a round;
|
|
255
|
+
- and a seed is in fact pinned for this run (`_sampling_is_pinned`). Mechanical,
|
|
256
|
+
so the gate checks it rather than taking it on trust: the affirmation is about
|
|
257
|
+
the backend, this is about the client actually in hand.
|
|
258
|
+
|
|
259
|
+
Without both, the same text may legitimately draw a different sample, and a
|
|
260
|
+
consumer whose next round would have scored above threshold keeps that round.
|
|
261
|
+
The cost of defaulting off is one re-judged verdict per repeat; the cost of
|
|
262
|
+
defaulting on is a lucky would-pass turned into an exhaustion, on a backend the
|
|
263
|
+
library advertises support for. Fail closed: pay for the call.
|
|
264
|
+
"""
|
|
265
|
+
if self._last_prompt == prompt and self._last_verdict is not None:
|
|
266
|
+
if self.deterministic_sampling and _sampling_is_pinned(self.client):
|
|
267
|
+
return self._last_verdict
|
|
268
|
+
verdict = structured(self.client, prompt, self.rubric.schema)
|
|
269
|
+
self._last_prompt, self._last_verdict = prompt, verdict
|
|
270
|
+
return verdict
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
class GroundedCritiqueGate(CritiqueGate):
|
|
274
|
+
"""CritiqueGate whose critic also sees the run's tool call/observation pairs."""
|
|
275
|
+
|
|
276
|
+
wants_transcript = True
|
|
277
|
+
|
|
278
|
+
def __init__(
|
|
279
|
+
self,
|
|
280
|
+
rubric: str | Rubric = "grounded-completion",
|
|
281
|
+
client: ModelClient | None = None,
|
|
282
|
+
max_rounds: int | None = None,
|
|
283
|
+
evidence_budget: int | None = None,
|
|
284
|
+
deterministic_sampling: bool = False,
|
|
285
|
+
):
|
|
286
|
+
super().__init__(
|
|
287
|
+
rubric,
|
|
288
|
+
client=client,
|
|
289
|
+
max_rounds=max_rounds,
|
|
290
|
+
deterministic_sampling=deterministic_sampling,
|
|
291
|
+
)
|
|
292
|
+
_validate_grounded_rubric(self.rubric)
|
|
293
|
+
self.evidence_budget = (
|
|
294
|
+
evidence_budget
|
|
295
|
+
if evidence_budget is not None
|
|
296
|
+
else profile_default("critique", "evidence_budget")
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
def __call__(self, task: str, output: str, messages: list[Message]) -> str | None:
|
|
300
|
+
evidence = render_evidence(messages, self.evidence_budget)
|
|
301
|
+
return self._judge(task=task, output=output, evidence=evidence)
|