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/contract.py ADDED
@@ -0,0 +1,522 @@
1
+ """Layer 2 — the model contract: every string the model reads, every parse of what it writes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+
8
+ import jsonschema
9
+ import yaml
10
+
11
+ from bantamkit.assets import AssetNotFound, assets_root
12
+ from bantamkit.client import BantamError, Message
13
+ from bantamkit.textutil import truncate
14
+
15
+ REQUIRED_KEYS = (
16
+ "schema_instruction",
17
+ "schema_retry",
18
+ "critique_feedback",
19
+ "parse_error",
20
+ "validation_error",
21
+ "json_answer_retry",
22
+ "loop_note",
23
+ "loop_warn",
24
+ "evidence_line",
25
+ "evidence_no_observation",
26
+ "evidence_empty",
27
+ "document_manifest_empty",
28
+ "document_manifest_part",
29
+ "document_manifest_header_row",
30
+ "document_manifest_first_row",
31
+ "document_manifest_last_row",
32
+ "document_manifest_package_media",
33
+ "document_manifest_omitted_media",
34
+ "document_manifest_omitted_blank",
35
+ "document_manifest_omitted_format",
36
+ "document_manifest_omitted_unread_page",
37
+ "document_manifest_unread_no_operator",
38
+ "document_manifest_unread_unmapped",
39
+ "document_manifest_unread_whitespace",
40
+ "document_manifest_unread_no_character",
41
+ "document_manifest_omitted_unmapped",
42
+ "document_manifest_omitted_other",
43
+ "document_page_header",
44
+ "document_page_next",
45
+ "document_page_end",
46
+ "document_page_truncated",
47
+ "document_unknown",
48
+ "document_offset_past_end",
49
+ "document_error",
50
+ "document_paste_preamble",
51
+ "document_paste_part",
52
+ "document_paste_complete",
53
+ "document_paste_truncated",
54
+ "document_paste_none",
55
+ "tool_failed",
56
+ "tool_arguments",
57
+ "tool_arguments_none",
58
+ "tool_argument_types",
59
+ "tool_argument_type",
60
+ )
61
+
62
+
63
+ def load_contract(name: str = "default") -> dict:
64
+ path = assets_root() / "contracts" / f"{name}.yaml"
65
+ if not path.exists():
66
+ raise AssetNotFound(f"contract asset not found: {path}")
67
+ data = yaml.safe_load(path.read_text(encoding="utf-8"))
68
+ missing = [k for k in REQUIRED_KEYS if k not in data]
69
+ if missing:
70
+ raise BantamError(f"contract '{name}' missing key(s): {', '.join(missing)}")
71
+ return data
72
+
73
+
74
+ def schema_instruction(schema: dict) -> str:
75
+ return load_contract()["schema_instruction"] + json.dumps(schema)
76
+
77
+
78
+ def schema_retry_feedback(error: str) -> str:
79
+ return load_contract()["schema_retry"].format(error=error)
80
+
81
+
82
+ def critique_feedback(score: int, threshold: int, feedback: str) -> str:
83
+ return load_contract()["critique_feedback"].format(
84
+ score=score, threshold=threshold, feedback=feedback
85
+ )
86
+
87
+
88
+ def json_answer_retry() -> str:
89
+ """Feedback for a final answer with no extractable JSON at all (P4)."""
90
+ return load_contract()["json_answer_retry"]
91
+
92
+
93
+ def loop_note(count: int) -> str:
94
+ """Injected when a tool has returned the same observation `count` times in a row."""
95
+ return load_contract()["loop_note"].format(count=count)
96
+
97
+
98
+ def loop_warn() -> str:
99
+ """The hard wording, past the warn threshold: stop calling tools, answer now."""
100
+ return load_contract()["loop_warn"]
101
+
102
+
103
+ def parse_error_message(detail: object) -> str:
104
+ return load_contract()["parse_error"].format(detail=detail)
105
+
106
+
107
+ def validation_error_message(where: str, detail: str) -> str:
108
+ return load_contract()["validation_error"].format(where=where, detail=detail)
109
+
110
+
111
+ def extract_json(text: str) -> dict | list:
112
+ text = text.strip()
113
+ fence = re.search(r"```(?:json)?\s*(.*?)```", text, re.DOTALL)
114
+ if fence:
115
+ text = fence.group(1).strip()
116
+ start_brace = text.find("{")
117
+ start_bracket = text.find("[")
118
+ if start_brace == -1 and start_bracket == -1:
119
+ raise ValueError("no JSON object found in output")
120
+ elif start_brace == -1:
121
+ start = start_bracket
122
+ elif start_bracket == -1:
123
+ start = start_brace
124
+ else:
125
+ start = min(start_brace, start_bracket)
126
+ obj, _ = json.JSONDecoder().raw_decode(text[start:])
127
+ return obj
128
+
129
+
130
+ def schema_error(output: str, schema: dict) -> str | None:
131
+ """Return a pointed validation error for `output`, or None if it satisfies `schema`."""
132
+ try:
133
+ data = extract_json(output)
134
+ except ValueError as e:
135
+ return parse_error_message(e)
136
+ try:
137
+ jsonschema.validate(data, schema)
138
+ except jsonschema.ValidationError as e:
139
+ where = "/".join(str(p) for p in e.absolute_path) or "root"
140
+ return validation_error_message(where, e.message)
141
+ return None
142
+
143
+
144
+ def render_evidence(messages: list[Message], budget: int = 4096) -> str:
145
+ """Tool call/observation pairs from a run's transcript, as critic-readable lines."""
146
+ contract = load_contract()
147
+ lines = []
148
+ consumed: set[int] = set()
149
+ for position, message in enumerate(messages):
150
+ for tc in message.tool_calls:
151
+ observation = contract["evidence_no_observation"]
152
+ for later in range(position + 1, len(messages)):
153
+ candidate = messages[later]
154
+ if (
155
+ later not in consumed
156
+ and candidate.role == "tool"
157
+ and candidate.tool_call_id == tc.id
158
+ ):
159
+ observation = candidate.content
160
+ consumed.add(later)
161
+ break
162
+ lines.append(
163
+ contract["evidence_line"].format(
164
+ name=tc.name,
165
+ arguments=json.dumps(tc.arguments),
166
+ observation=observation,
167
+ )
168
+ )
169
+ if not lines:
170
+ return contract["evidence_empty"]
171
+ return truncate("\n".join(lines), budget)
172
+
173
+
174
+ # ---- the document reader pair (`document_list`, `document_read`) ------------------------
175
+ #
176
+ # These render what the model reads, so they live here and take PRIMITIVES: `contract.py`
177
+ # may not import `docread` (`test_layers.py::test_import_direction`), and it does not need
178
+ # to — a rendered row is already a string by the time a reader has one.
179
+
180
+
181
+ def _omission_line(contract: dict, entry: dict, omission: dict) -> str:
182
+ """One omission, as one line. An unknown subject is PRINTED, never dropped.
183
+
184
+ That fallback is the whole point of the mechanism. `docread` can add a subject without
185
+ this layer being updated in the same breath, and the failure mode of that lag has to be a
186
+ line the model reads as slightly generic — never a count that vanishes, which is exactly
187
+ the silence the omission record was added to end.
188
+ """
189
+ subject = omission["subject"]
190
+ if subject == "media":
191
+ return contract["document_manifest_omitted_media"].format(
192
+ count=omission["count"], bytes=omission["size"]
193
+ )
194
+ if subject == "blank-rows":
195
+ return contract["document_manifest_omitted_blank"].format(
196
+ count=omission["count"], rows=entry["row_count"]
197
+ )
198
+ if subject == "number-format":
199
+ return contract["document_manifest_omitted_format"].format(
200
+ where=", ".join(omission["where"]),
201
+ count=omission["count"],
202
+ what=omission["what"],
203
+ )
204
+ if subject == "unread-page":
205
+ # A PDF page that rendered nothing, and WHICH of the four reasons. The reader ships
206
+ # four named counts and no prose; choosing between them is this layer's job, exactly as
207
+ # `docread._pdf_refusal` chooses between the same four one grain up. The branch order
208
+ # is that function's, so the page-grain sentence and the document-grain refusal cannot
209
+ # disagree about a file.
210
+ #
211
+ # `vouched` is the whitespace case: on a page with no rows, a character this reader
212
+ # mapped can only be sitting in a run the renderer dropped for stripping to nothing.
213
+ # That sentence is the one this fix exists for — it was previously indistinguishable
214
+ # from the last branch, which blames the reader, and it is not the reader's fault.
215
+ facts = omission.get("facts") or {}
216
+ show_ops = int(facts.get("show_ops", 0))
217
+ vouched = int(facts.get("vouched", 0))
218
+ unmapped = int(facts.get("unmapped", 0))
219
+ if show_ops == 0:
220
+ why = contract["document_manifest_unread_no_operator"]
221
+ elif vouched == 0 and unmapped:
222
+ why = contract["document_manifest_unread_unmapped"].format(
223
+ show_ops=show_ops, unmapped=unmapped
224
+ )
225
+ elif vouched:
226
+ why = contract["document_manifest_unread_whitespace"].format(
227
+ show_ops=show_ops, vouched=vouched
228
+ )
229
+ else:
230
+ why = contract["document_manifest_unread_no_character"].format(show_ops=show_ops)
231
+ return contract["document_manifest_omitted_unread_page"].format(
232
+ why=why,
233
+ count=int(facts.get("images", 0)),
234
+ bytes=int(facts.get("image_bytes", 0)),
235
+ )
236
+ if subject == "unmapped-text":
237
+ return contract["document_manifest_omitted_unmapped"].format(
238
+ count=omission["count"], what=omission["what"]
239
+ )
240
+ return contract["document_manifest_omitted_other"].format(
241
+ count=omission["count"], subject=subject, what=omission["what"]
242
+ )
243
+
244
+
245
+ def document_manifest(parts: list[dict], documents: list[dict] | None = None) -> str:
246
+ """The answer to "what exists", as one observation.
247
+
248
+ This is the design decision of the pair, written out. A corpus of 12,001 rows cannot be
249
+ paged blind: 240 pages at the default limit against a 10-turn budget is a measurement of
250
+ the tool's ergonomics reported as the model's competence. So the first call states, per
251
+ part, the row COUNT and the row NUMBERING, plus three actual rows — the header (which
252
+ column is which), the first data row and the last. Those three are what make an offset
253
+ guessable: a key column whose first and last values are visible tells the model where a
254
+ row it has never seen must sit, and `document_read`'s `offset` takes it there.
255
+
256
+ It is deliberately not a search: it is a fixed description, identical on every call, and
257
+ it names no value the caller asked about. A `find`/`filter` argument would be a finder
258
+ primitive, which is a different axis and would make this job unable to say whether the
259
+ READER bought anything.
260
+
261
+ **And it states what the rendering left out.** J25 measured the alternative on the user's
262
+ own files: for an 18.62 MB workbook this said `4 parts, 28 rows` and stopped, when all 28
263
+ of those rows are empty lines and the file's content is 56 embedded images. A row count
264
+ with nothing beside it is not a neutral omission — it is a claim about what is there. So
265
+ each part's `omissions:` (and each document's, for what belongs to no part) is rendered
266
+ as a COUNT, right where the count it qualifies is stated. Both keys are optional and an
267
+ entry without them renders byte-for-byte as it did before, which is what keeps J10's nine
268
+ committed `document-read` rows where they are.
269
+ """
270
+ contract = load_contract()
271
+ if not parts:
272
+ return contract["document_manifest_empty"]
273
+ package = {entry["document"]: entry.get("omissions") or [] for entry in documents or []}
274
+ lines = []
275
+ for entry in parts:
276
+ rows: list[str] = entry["rows"]
277
+ last = entry["row_count"] - 1
278
+ for omission in package.pop(entry["document"], []):
279
+ if omission["subject"] == "media":
280
+ lines.append(
281
+ contract["document_manifest_package_media"].format(
282
+ document=entry["document"],
283
+ count=omission["count"],
284
+ what=omission["what"],
285
+ bytes=omission["size"],
286
+ )
287
+ )
288
+ else:
289
+ lines.append(_omission_line(contract, entry, omission))
290
+ lines.append(
291
+ contract["document_manifest_part"].format(
292
+ document=entry["document"],
293
+ kind=entry["kind"],
294
+ index=entry["index"],
295
+ part=entry["part"],
296
+ rows=entry["row_count"],
297
+ last=last,
298
+ )
299
+ )
300
+ if rows:
301
+ lines.append(contract["document_manifest_header_row"].format(row=rows[0]))
302
+ if len(rows) > 1:
303
+ lines.append(contract["document_manifest_first_row"].format(row=rows[1]))
304
+ if len(rows) > 2:
305
+ lines.append(
306
+ contract["document_manifest_last_row"].format(index=last, row=rows[-1])
307
+ )
308
+ for omission in entry.get("omissions") or []:
309
+ lines.append(_omission_line(contract, entry, omission))
310
+ # A document whose omissions matched no part above still gets said. `pop` is what makes
311
+ # this reachable at all, and it is reachable: a package can declare a sheet the manifest
312
+ # never lists. Dropping the line here would put the silence back one level up.
313
+ for document, omissions in package.items():
314
+ for omission in omissions:
315
+ lines.append(
316
+ contract["document_manifest_package_media"].format(
317
+ document=document,
318
+ count=omission["count"],
319
+ what=omission["what"],
320
+ bytes=omission["size"],
321
+ )
322
+ if omission["subject"] == "media"
323
+ else _omission_line(contract, {"row_count": 0}, omission)
324
+ )
325
+ return "\n".join(lines)
326
+
327
+
328
+ def document_page(
329
+ document: str,
330
+ part: str,
331
+ offset: int,
332
+ rows: list[str],
333
+ row_count: int,
334
+ next_offset: int | None,
335
+ truncated_bytes: int = 0,
336
+ next_key: str = "document_page_next",
337
+ ) -> str:
338
+ """One page, with its own coordinates and its continuation, both in band.
339
+
340
+ Each line is prefixed with its own row number. `next_offset` is a number the caller has
341
+ to be able to act on, so it is spelled as the call to make and not left as a field: a
342
+ page that ends without saying how to get the next one is a page the model re-reads.
343
+
344
+ `next_key` names the contract sentence for that continuation, because the sentence
345
+ names the TOOL to call again: `document_read` for the eval pair, `bantamkit_read` for
346
+ the MCP reader (job43). Every other line of a page is the same bytes on both.
347
+ """
348
+ contract = load_contract()
349
+ end = offset + len(rows) - 1 if rows else offset
350
+ lines = [
351
+ contract["document_page_header"].format(
352
+ document=document, part=part, start=offset, end=end, rows=row_count
353
+ )
354
+ ]
355
+ lines += [f"{offset + i}\t{row}" for i, row in enumerate(rows)]
356
+ if truncated_bytes:
357
+ lines.append(
358
+ contract["document_page_truncated"].format(index=end, dropped=truncated_bytes)
359
+ )
360
+ if next_offset is None:
361
+ lines.append(contract["document_page_end"].format(part=part))
362
+ else:
363
+ lines.append(contract[next_key].format(next_offset=next_offset))
364
+ return "\n".join(lines)
365
+
366
+
367
+ def document_unknown(name: str, available: list[str]) -> str:
368
+ return load_contract()["document_unknown"].format(name=name, available=", ".join(available))
369
+
370
+
371
+ def document_offset_past_end(part: str, offset: int, row_count: int) -> str:
372
+ return load_contract()["document_offset_past_end"].format(
373
+ part=part, offset=offset, rows=row_count, last=row_count - 1
374
+ )
375
+
376
+
377
+ def bantamkit_read_unknown_part(part: str, path: str, available: list[str]) -> str:
378
+ """`bantamkit_read` was asked for a part the file does not have.
379
+
380
+ Its own sentence rather than `document_unknown`'s, because a missing part is a fact
381
+ about the FILE ("in {path}; it has: ...") and not about "this task". The available
382
+ names are joined the way `document_unknown` joins its documents.
383
+ """
384
+ return load_contract()["bantamkit_read_unknown_part"].format(
385
+ part=part, path=path, available=", ".join(available)
386
+ )
387
+
388
+
389
+ def document_error(detail: object) -> str:
390
+ """A reader failure, kept in the reader's own words.
391
+
392
+ `docread` was built to name what it saw rather than to re-raise the format's error, so
393
+ the useful sentence already exists; this puts the `error:` prefix on it that every other
394
+ tool observation in the harness uses.
395
+ """
396
+ return load_contract()["document_error"].format(detail=detail)
397
+
398
+
399
+ # ---- the `paste` arm's system message (no reader attached) -------------------------------
400
+
401
+
402
+ def document_paste(parts: list[dict]) -> str:
403
+ """The whole of what the `paste` arm tells the model, rendered from primitives.
404
+
405
+ The bar's §10.2 clause 4: the message states the part name, the total row count and how
406
+ many rows are shown, so the model is TOLD the paste is partial rather than left to infer
407
+ it from a sheet that simply stops. That is why the completeness sentence is a separate
408
+ contract string per case and not an `if` inside a format argument — the three cases
409
+ (complete, partial, nothing fitted) say different things to the model and each is
410
+ byte-pinned.
411
+
412
+ The rows are passed in already sliced and already rendered, and are emitted VERBATIM with
413
+ no row-number prefix. `document_page` prefixes its rows because a pager's caller has to
414
+ compute the next offset; a paste has no next call to make, and a prefix would put bytes in
415
+ front of the model that the bar's PASTE_MAX_BYTES accounting (each row plus its newline)
416
+ does not count. Clause 2's "the same bytes `document_read` would return" is about
417
+ docread's rendering of the row, which is what arrives here.
418
+ """
419
+ contract = load_contract()
420
+ lines = [contract["document_paste_preamble"]]
421
+ for entry in parts:
422
+ rows: list[str] = entry["rows"]
423
+ total = entry["row_count"]
424
+ last = total - 1
425
+ lines.append(
426
+ contract["document_paste_part"].format(
427
+ document=entry["document"],
428
+ kind=entry["kind"],
429
+ index=entry["index"],
430
+ part=entry["part"],
431
+ rows=total,
432
+ last=last,
433
+ shown=len(rows),
434
+ )
435
+ )
436
+ if not rows:
437
+ lines.append(contract["document_paste_none"].format(part=entry["part"]))
438
+ continue
439
+ if len(rows) == total:
440
+ lines.append(
441
+ contract["document_paste_complete"].format(
442
+ part=entry["part"], last_shown=len(rows) - 1
443
+ )
444
+ )
445
+ else:
446
+ lines.append(
447
+ contract["document_paste_truncated"].format(
448
+ part=entry["part"],
449
+ last_shown=len(rows) - 1,
450
+ first_missing=len(rows),
451
+ last=last,
452
+ )
453
+ )
454
+ lines.extend(rows)
455
+ return "\n".join(lines)
456
+
457
+
458
+ # ---- what the model is told when a tool CALL cannot be made, or fails --------------------
459
+ #
460
+ # Distinct from the observations above: those are a tool ANSWERING with its own error, in its
461
+ # own words, about input it understood. These two are the dispatcher's, and they exist because
462
+ # the dispatcher had none. Before 2026-08-20 `Agent._dispatch` formatted its own sentence and
463
+ # interpolated the exception object, so the one failure a small model actually produces —
464
+ # one extra argument on a tool that declares no properties — reached the model as
465
+ # `_document_tools.<locals>.list_documents() got an unexpected keyword argument 'document'`.
466
+ # That is model-facing text, it is Python's wording and not this repository's, no contract
467
+ # asset owned it and no golden pinned it. X5 measured it on 5 of 5 seeds on the 4b.
468
+
469
+
470
+ def tool_failed(tool: str, detail: object) -> str:
471
+ """A handler raised. `detail` is the handler's own words, never a signature fragment.
472
+
473
+ The argument-shaped failures — an undeclared key, a missing one — are settled before the
474
+ handler is called (`agent.select_declared_arguments` and the bind check in `_dispatch`),
475
+ so what reaches here is an exception from INSIDE a handler. Every tool in this repository
476
+ authors its model-facing errors deliberately (`docread` was built to name what it saw
477
+ rather than re-raise the format's error), and an exception that escapes one anyway is a
478
+ defect whose message is the most useful thing the transcript can carry for the operator.
479
+ """
480
+ return load_contract()["tool_failed"].format(tool=tool, detail=detail)
481
+
482
+
483
+ def tool_arguments(tool: str, accepts: list[str]) -> str:
484
+ """The call could not be made with the arguments given: name what the tool does take.
485
+
486
+ The repository's convention for telling a model what it got wrong is to name what the
487
+ tool saw and what it has (`document_unknown`: "this task has: ..."). This follows it. The
488
+ empty case is a SEPARATE contract string rather than an `if` inside a format argument,
489
+ the same choice `document_paste` made for its three completeness cases: "it takes: " with
490
+ nothing after it is not a sentence, and a tool that takes nothing at all is a different
491
+ thing to say than a tool whose argument list the model got wrong.
492
+ """
493
+ contract = load_contract()
494
+ if not accepts:
495
+ return contract["tool_arguments_none"].format(tool=tool)
496
+ return contract["tool_arguments"].format(tool=tool, accepts=", ".join(accepts))
497
+
498
+
499
+ def tool_argument_types(tool: str, problems: list[tuple[str, str, str]]) -> str:
500
+ """A DECLARED argument arrived with a type its schema does not declare.
501
+
502
+ `tool_arguments` above is about a name the tool does not have; this is about a name it
503
+ does have holding a value it cannot use. The distinction is the whole of `RB-P86`:
504
+ Amendment 1 stopped an UNDECLARED key from crashing a handler, and nothing stopped a
505
+ declared key of the wrong type, so `llama3.2:3b` reached `docs.get(name)` with a dict and
506
+ the model was handed `unhashable type: 'dict'` — CPython's words about a hash table, on
507
+ 13 of 432 graded runs. Re-derived from the committed transcripts: 0 of the 13 passed and
508
+ 11 of the 13 issued no further tool call of any kind, answering instead with invented
509
+ tool-call JSON. A sentence naming the argument is something the next turn can correct.
510
+
511
+ `problems` is `(argument, declared type, sent type)`, and every one of the three is data
512
+ off the schema and the call rather than a sentence: the wording is the asset's, and the
513
+ item is a SEPARATE contract string so that a call with three wrong arguments is one
514
+ sentence naming three, not three sentences or a count the model cannot act on.
515
+ """
516
+ contract = load_contract()
517
+ item = contract["tool_argument_type"]
518
+ rendered = "; ".join(
519
+ item.format(argument=argument, expected=expected, actual=actual)
520
+ for argument, expected, actual in problems
521
+ )
522
+ return contract["tool_argument_types"].format(tool=tool, problems=rendered)