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/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)
|