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.
Files changed (119) hide show
  1. bantamkit/__init__.py +32 -0
  2. bantamkit/agent.py +458 -0
  3. bantamkit/assets/contracts/default.yaml +90 -0
  4. bantamkit/assets/evals/devteam/manifest.yaml +351 -0
  5. bantamkit/assets/evals/devteam/repo/HISTORY.md +18 -0
  6. bantamkit/assets/evals/devteam/repo/README.md +12 -0
  7. bantamkit/assets/evals/devteam/repo/docs/architecture.md +17 -0
  8. bantamkit/assets/evals/devteam/repo/docs/runbook.md +10 -0
  9. bantamkit/assets/evals/devteam/repo/issues/142-settlement-timeout.md +23 -0
  10. bantamkit/assets/evals/devteam/repo/patches/0009-retry-budget.patch +38 -0
  11. bantamkit/assets/evals/devteam/repo/src/ledger/__init__.py +3 -0
  12. bantamkit/assets/evals/devteam/repo/src/ledger/config.py +35 -0
  13. bantamkit/assets/evals/devteam/repo/src/ledger/errors.py +13 -0
  14. bantamkit/assets/evals/devteam/repo/src/ledger/posting.py +12 -0
  15. bantamkit/assets/evals/devteam/repo/src/ledger/registry.py +7 -0
  16. bantamkit/assets/evals/devteam/repo/src/ledger/report.py +9 -0
  17. bantamkit/assets/evals/devteam/repo/src/ledger/retry.py +17 -0
  18. bantamkit/assets/evals/devteam/repo/src/ledger/settle.py +16 -0
  19. bantamkit/assets/evals/devteam/repo/src/ledger/validate.py +14 -0
  20. bantamkit/assets/evals/devteam/repo/tests/test_posting.py +13 -0
  21. bantamkit/assets/evals/devteam/repo/tests/test_settle.py +9 -0
  22. bantamkit/assets/evals/devteam/tasks/dt-error-contract.yaml +186 -0
  23. bantamkit/assets/evals/devteam/tasks/dt-handler-map.yaml +183 -0
  24. bantamkit/assets/evals/devteam/tasks/dt-patch-before-after.yaml +182 -0
  25. bantamkit/assets/evals/devteam/tasks/dt-retry-attempts.yaml +181 -0
  26. bantamkit/assets/evals/devteam/tasks/dt-settlement-config.yaml +185 -0
  27. bantamkit/assets/evals/devteam/tasks/dt-symbol-home.yaml +181 -0
  28. bantamkit/assets/evals/devteam/tasks/dt-trace-blame.yaml +182 -0
  29. bantamkit/assets/evals/devteam/tasks/dt-unread-key.yaml +180 -0
  30. bantamkit/assets/evals/document/tasks/doc-large-in-137.yaml +38 -0
  31. bantamkit/assets/evals/document/tasks/doc-large-in-359.yaml +44 -0
  32. bantamkit/assets/evals/document/tasks/doc-large-in-372.yaml +38 -0
  33. bantamkit/assets/evals/document/tasks/doc-large-out-11764.yaml +37 -0
  34. bantamkit/assets/evals/document/tasks/doc-large-out-4137.yaml +37 -0
  35. bantamkit/assets/evals/document/tasks/doc-large-out-8022.yaml +37 -0
  36. bantamkit/assets/evals/document/tasks/doc-small-137.yaml +37 -0
  37. bantamkit/assets/evals/document/tasks/doc-small-261.yaml +37 -0
  38. bantamkit/assets/evals/document/tasks/doc-small-388.yaml +37 -0
  39. bantamkit/assets/evals/fixtures/.gitkeep +0 -0
  40. bantamkit/assets/evals/fixtures/catalog.json +6 -0
  41. bantamkit/assets/evals/perturbations/task-completion.yaml +576 -0
  42. bantamkit/assets/evals/tasks/.gitkeep +0 -0
  43. bantamkit/assets/evals/tasks/extract-contact.yaml +14 -0
  44. bantamkit/assets/evals/tasks/extract-invoice.yaml +14 -0
  45. bantamkit/assets/evals/tasks/extract-order.yaml +15 -0
  46. bantamkit/assets/evals/tasks/extract-schedule.yaml +14 -0
  47. bantamkit/assets/evals/tasks/extract-versions.yaml +17 -0
  48. bantamkit/assets/evals/tasks/nav-prod-port.yaml +84 -0
  49. bantamkit/assets/evals/tasks/nav-release-bundle.yaml +87 -0
  50. bantamkit/assets/evals/tasks/recall-audit-retention.yaml +17 -0
  51. bantamkit/assets/evals/tasks/recall-cache-ttl.yaml +13 -0
  52. bantamkit/assets/evals/tasks/recall-db-port.yaml +17 -0
  53. bantamkit/assets/evals/tasks/recall-deploy.yaml +13 -0
  54. bantamkit/assets/evals/tasks/recall-env-endpoint.yaml +18 -0
  55. bantamkit/assets/evals/tasks/recall-oncall-rotation.yaml +21 -0
  56. bantamkit/assets/evals/tasks/recall-oncall.yaml +13 -0
  57. bantamkit/assets/evals/tasks/recall-org-quota.yaml +18 -0
  58. bantamkit/assets/evals/tasks/recall-owner.yaml +13 -0
  59. bantamkit/assets/evals/tasks/shop-basket-total.yaml +10 -0
  60. bantamkit/assets/evals/tasks/shop-cheapest.yaml +9 -0
  61. bantamkit/assets/evals/tasks/shop-compare.yaml +9 -0
  62. bantamkit/assets/evals/tasks/shop-gadget-value.yaml +9 -0
  63. bantamkit/assets/evals/tasks/shop-stock-total.yaml +9 -0
  64. bantamkit/assets/evals/tasks/shop-total.yaml +9 -0
  65. bantamkit/assets/profiles/default.yaml +31 -0
  66. bantamkit/assets/profiles/patient.yaml +31 -0
  67. bantamkit/assets/rubrics/.gitkeep +0 -0
  68. bantamkit/assets/rubrics/code-quality.yaml +20 -0
  69. bantamkit/assets/rubrics/grounded-completion.yaml +37 -0
  70. bantamkit/assets/rubrics/task-completion.yaml +28 -0
  71. bantamkit/assets/schemas/shiftwork-checkpoint.json +188 -0
  72. bantamkit/assets/skills/.gitkeep +0 -0
  73. bantamkit/assets/skills/file-graph.md +7 -0
  74. bantamkit/assets/skills/memory.md +35 -0
  75. bantamkit/assets/tools/.gitkeep +0 -0
  76. bantamkit/assets/tools/bantamkit_read.json +48 -0
  77. bantamkit/assets/tools/bantamkit_status.json +25 -0
  78. bantamkit/assets/tools/build_identity.json +17 -0
  79. bantamkit/assets/tools/document_list.json +12 -0
  80. bantamkit/assets/tools/document_read.json +31 -0
  81. bantamkit/assets/tools/file_graph.json +12 -0
  82. bantamkit/assets/tools/memory_compact.json +31 -0
  83. bantamkit/assets/tools/memory_recall.json +38 -0
  84. bantamkit/assets/tools/memory_save.json +61 -0
  85. bantamkit/assets/tools/shiftwork_clock_in.json +25 -0
  86. bantamkit/assets/tools/shiftwork_clock_out.json +60 -0
  87. bantamkit/assets/tools/shiftwork_status.json +25 -0
  88. bantamkit/assets/tools/skill_audit.json +70 -0
  89. bantamkit/assets/tools/validate_json.json +31 -0
  90. bantamkit/assets.py +67 -0
  91. bantamkit/budget.py +114 -0
  92. bantamkit/client.py +329 -0
  93. bantamkit/contract.py +522 -0
  94. bantamkit/criticreplay.py +3241 -0
  95. bantamkit/critique.py +301 -0
  96. bantamkit/docread.py +1744 -0
  97. bantamkit/evalrun.py +2003 -0
  98. bantamkit/eventlog.py +282 -0
  99. bantamkit/filegraph.py +218 -0
  100. bantamkit/loopguard.py +101 -0
  101. bantamkit/mcpreport.py +763 -0
  102. bantamkit/mcpserver.py +1334 -0
  103. bantamkit/memory/__init__.py +28 -0
  104. bantamkit/memory/__main__.py +291 -0
  105. bantamkit/memory/component.py +569 -0
  106. bantamkit/memory/divergence.py +744 -0
  107. bantamkit/memory/layers.py +257 -0
  108. bantamkit/memory/store.py +940 -0
  109. bantamkit/pdfread.py +1402 -0
  110. bantamkit/profile.py +46 -0
  111. bantamkit/shiftwork.py +212 -0
  112. bantamkit/skillaudit.py +853 -0
  113. bantamkit/statusline.py +313 -0
  114. bantamkit/structured.py +125 -0
  115. bantamkit/textutil.py +30 -0
  116. bantamkit-0.27.0.dist-info/METADATA +207 -0
  117. bantamkit-0.27.0.dist-info/RECORD +119 -0
  118. bantamkit-0.27.0.dist-info/WHEEL +4 -0
  119. 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)