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
@@ -0,0 +1,313 @@
1
+ """One line for a host's status bar, rendered from what a finished session left on disk.
2
+
3
+ WHAT THIS IS AND WHY IT IS NOT A SIXTH HOST. `bantamkit_status` and its prompt
4
+ (`docs/status.md`) are surfaces a host renders ON DEMAND: the model calls a tool, or a
5
+ person invokes a slash command. This one is rendered WITHOUT ANYONE ASKING, on the
6
+ host's own redraw path. That is the whole point of it, and it exists in **Claude Code
7
+ only** -- `statusLine` is a key in `~/.claude/settings.json` and has no analogue in
8
+ Claude Desktop, in Copilot CLI, or in either VS Code extension. It does not generalise
9
+ and nothing here should be written as though it will.
10
+
11
+ THREE PROPERTIES, EACH THE OPPOSITE OF A DEFECT ALREADY MEASURED IN THIS PRODUCT.
12
+
13
+ * **It never starts a server.** Nothing in this module opens a transport, builds a
14
+ `Memory`, or imports `mcpserver`. It reads at most one file. A status bar that spawned
15
+ an MCP server per redraw would cost more than everything it reports on.
16
+ * **It never fails loudly.** No traceback, no stderr, no empty line. `status_line` is
17
+ total: every path returns a string, and the outermost `except Exception` turns a
18
+ surprise into the UNKNOWN state. Job39 measured the alternative twice -- the packaged
19
+ `bin` dies with a raw Node stack trace, and the default memory store resolves against
20
+ `cwd` so a GUI host launched at `/` kills the server -- and both of those are what a
21
+ redraw path must not reproduce. **Unknown is a state to render, not an error to raise.**
22
+ * **It reads what is already true.** No clock, no probe, no heartbeat, no timer.
23
+
24
+ WHY THE EVENT LOG AND NOT `--mcp-report`. Two sources were defensible and a third would
25
+ have been an invention. `--mcp-report` (`docs/mcpreport.md`) reads the event log too,
26
+ but it reaches it through the HOST's log first. Measured on this machine 2026-08-24, 25
27
+ runs each: bare `node -e 0` p50 **18.1 ms**, `--statusline` p50 **69.4 ms**,
28
+ `--mcp-report` p50 **88.6 ms** over 22 slug directories, 112 files and 1674 records.
29
+
30
+ The 19 ms is the smaller half of the reason. The larger half is that **source A is
31
+ unbounded and bantamkit does not own it**: the host writes it, never rotates it, and it
32
+ was 1533 records when `docs/eventlog.md` was written and 1674 five days later. A redraw
33
+ path may not have its cost set by a file another program grows. Source B is bounded by
34
+ its own rotation rule at 1 MiB, and this reader bounds it again at `TAIL_BYTES`.
35
+
36
+ The third reason is shape. `--mcp-report` prints forty lines; a status bar takes one, so
37
+ an adapter over it would have to PARSE the report -- a third representation of a state
38
+ that already has two. `bantamkit-mcp --statusline` renders the one line directly.
39
+
40
+ WHAT THE LINE CAN AND CANNOT SAY. The four conditions in `docs/status.md` are computed
41
+ by a RUNNING server, and one of them (`event-log-unwritable`) is process state that no
42
+ outside reader can see at all. This module does not recompute them and does not pretend
43
+ to. What it has is the event log's own closed `outcome` vocabulary, and three of those
44
+ values name a failure of the server's own state rather than a property of the caller's
45
+ input -- see `ADVERSE`. A window that contains one of them is DEGRADED.
46
+
47
+ METADATA ONLY, THE SAME RULE THE LOG ITSELF IS UNDER, AND HERE IT IS STRUCTURAL. The
48
+ parser below keeps exactly two strings per record: a `tool` that matched
49
+ `^[a-z0-9_]{1,64}$` and an `outcome` that is a member of `OUTCOMES`. `detail` is never
50
+ read, `ts` is never read, `v` is never read. There is no field on the parsed record that
51
+ could hold borrowed text, so no code path can print one -- the same guard
52
+ `mcpreport.py` uses against the host's log, applied here to bantamkit's own, because the
53
+ log is a file on disk that anything can append to.
54
+
55
+ THE CROSS-RUNTIME CONTRACT IS `docs/statusline.md`. `runtime-ts/src/statusline.ts`
56
+ emits the same bytes and `tools/conformance/suites/statusline.mjs` compares the two as
57
+ processes. Change the rendering there and in both runtimes, or not at all.
58
+ """
59
+
60
+ from __future__ import annotations
61
+
62
+ import json
63
+ import re
64
+ from dataclasses import dataclass
65
+ from pathlib import Path
66
+
67
+ from bantamkit.mcpreport import resolve_event_log_path
68
+
69
+ #: How many of the log's most recent records the line describes.
70
+ #:
71
+ #: The bar answers "what is happening now", not "what has ever happened" -- that second
72
+ #: question is `--mcp-report`'s and it reads both generations to answer it. A fixed
73
+ #: window also makes the two counts on the line bounded, so the rendered string cannot
74
+ #: grow with the size of somebody's log.
75
+ WINDOW = 50
76
+
77
+ #: The most bytes read off the end of the log, ever.
78
+ #:
79
+ #: The log rotates itself at 1 MiB (`eventlog.CAP_BYTES`), so under bantamkit's own
80
+ #: default this branch never fires. It fires when `BANTAMKIT_EVENT_LOG` names a LITERAL
81
+ #: path -- an operator can point that at anything, and a redraw path may not be at the
82
+ #: mercy of how big that anything is. A partial first line is discarded, so the tail is
83
+ #: parsed identically whether or not it was truncated.
84
+ #:
85
+ #: The rotated generation `<path>.1` is NOT read at all. It is history, and history is
86
+ #: the analyst's job; a bar that reached back into it would report a fault the current
87
+ #: session has already replaced.
88
+ TAIL_BYTES = 262144
89
+
90
+ #: Every `outcome` `docs/eventlog.md` defines, and nothing else is renderable.
91
+ #:
92
+ #: A closed set, not a shape check: this is the guard that keeps an appended line from
93
+ #: putting text of its own choosing on the operator's status bar.
94
+ OUTCOMES = frozenset(
95
+ {
96
+ # memory_save
97
+ "saved",
98
+ "duplicate",
99
+ "refused-validation",
100
+ "refused-budget",
101
+ # memory_recall
102
+ "answered",
103
+ "empty-no-match",
104
+ "empty-unreadable-layer",
105
+ "empty-nothing-saved",
106
+ # validate_json
107
+ "valid",
108
+ "invalid",
109
+ # shiftwork_clock_in
110
+ "brief",
111
+ "escalate",
112
+ "success",
113
+ "error",
114
+ # shiftwork_clock_out / shiftwork_status
115
+ "ok",
116
+ "status",
117
+ # build_identity
118
+ "complete",
119
+ "partial",
120
+ # any handler that fell over
121
+ "raised",
122
+ }
123
+ )
124
+
125
+ #: The three outcomes that mean BANTAMKIT is in trouble, rather than the caller's input.
126
+ #:
127
+ #: * `raised` -- a handler fell over. There is no reading of that which is fine.
128
+ #: * `refused-budget` -- the store's index budget stopped a save. This is the same
129
+ #: pressure `docs/status.md`'s `index-budget-low` warns about one step earlier; by the
130
+ #: time it is in the log the refusal has already happened.
131
+ #: * `empty-unreadable-layer` -- a bound memory layer could not be listed. That is
132
+ #: `docs/status.md`'s `memory-layer-unreadable`, observed from the outside.
133
+ #:
134
+ #: Deliberately NOT here: `refused-validation` (the caller handed over a bad memory),
135
+ #: `error` and `escalate` (the shiftwork register composing a refusal about a checkpoint,
136
+ #: which is the tool working), `invalid` (a schema said no, which is the answer), and
137
+ #: every `empty-*` that is simply nothing to recall. A bar that turned orange because a
138
+ #: user typo'd a fact name is a bar the user learns to ignore.
139
+ ADVERSE = ("raised", "refused-budget", "empty-unreadable-layer")
140
+
141
+ #: The host's log validates tool names with exactly this and so does `mcpreport.py`.
142
+ _TOOL = re.compile(r"^[a-z0-9_]{1,64}$")
143
+
144
+ #: U+1F7E2 / U+1F7E0 are the same two `docs/status.md` pins; U+26AA is this file's.
145
+ ACTIVE = "bantamkit Active 🟢"
146
+ DEGRADED = "bantamkit Degraded 🟠"
147
+ UNKNOWN = "bantamkit Unknown ⚪"
148
+
149
+ #: The separator. One space, U+00B7, one space.
150
+ SEP = " · "
151
+
152
+ #: Every reason the UNKNOWN state can carry. A closed set, like `OUTCOMES`.
153
+ OFF = "event log off"
154
+ ABSENT = "no event log yet"
155
+ UNREADABLE = "event log unreadable"
156
+ EMPTY = "event log empty"
157
+ SURPRISE = "state unavailable"
158
+
159
+
160
+ @dataclass(frozen=True)
161
+ class Reading:
162
+ """What one look at the log found. Integers and closed-vocabulary tokens only."""
163
+
164
+ state: str
165
+ """`active`, `degraded` or `unknown`."""
166
+
167
+ events: int = 0
168
+ """Records in the window. `0` in every unknown state."""
169
+
170
+ problems: int = 0
171
+ """Records in the window whose outcome is in `ADVERSE`."""
172
+
173
+ tool: str = ""
174
+ """The tool of the record the line names, validated against `_TOOL`."""
175
+
176
+ outcome: str = ""
177
+ """That record's outcome, a member of `OUTCOMES`."""
178
+
179
+ reason: str = ""
180
+ """Why the state is unknown. Empty otherwise."""
181
+
182
+
183
+ def _unknown(reason: str) -> Reading:
184
+ return Reading(state="unknown", reason=reason)
185
+
186
+
187
+ def read_tail(path: Path) -> bytes:
188
+ """The last `TAIL_BYTES` of `path`, with a partial leading line discarded.
189
+
190
+ Opened in BINARY mode and never decoded as a whole: the log is UTF-8 by contract but
191
+ a truncated tail can start mid-codepoint, and a `UnicodeDecodeError` on the whole
192
+ buffer would throw away every intact record behind it. Each line is decoded on its
193
+ own inside `parse`, so one bad line costs one record.
194
+
195
+ Raises `OSError` -- the caller turns that into a rendered state.
196
+ """
197
+ with open(path, "rb") as handle:
198
+ handle.seek(0, 2)
199
+ size = handle.tell()
200
+ start = size - TAIL_BYTES if size > TAIL_BYTES else 0
201
+ handle.seek(start)
202
+ data = handle.read()
203
+ if start:
204
+ cut = data.find(b"\n")
205
+ data = b"" if cut < 0 else data[cut + 1 :]
206
+ return data
207
+
208
+
209
+ def parse(data: bytes) -> list[tuple[str, str]]:
210
+ """`(tool, outcome)` for every line that is a record this module is willing to render.
211
+
212
+ THIS IS THE LEAK GUARD AND IT IS STRUCTURAL, NOT A FILTER. What comes back is a list
213
+ of pairs of validated tokens; there is no field here that can hold a path, a memory
214
+ body, a query, or a `detail` value, so no later code CAN print one. A record with an
215
+ unknown outcome, a tool that is not `^[a-z0-9_]{1,64}$`, a non-object body, or
216
+ unparseable JSON is dropped whole -- it is not rendered under some fallback spelling,
217
+ because a fallback spelling is a way for the file's own bytes to reach the bar.
218
+ """
219
+ pairs: list[tuple[str, str]] = []
220
+ for raw in data.split(b"\n"):
221
+ if not raw:
222
+ continue
223
+ try:
224
+ record = json.loads(raw.decode("utf-8"))
225
+ except (ValueError, UnicodeDecodeError):
226
+ continue
227
+ if not isinstance(record, dict):
228
+ continue
229
+ tool = record.get("tool")
230
+ outcome = record.get("outcome")
231
+ if not isinstance(tool, str) or not isinstance(outcome, str):
232
+ continue
233
+ if _TOOL.match(tool) is None or outcome not in OUTCOMES:
234
+ continue
235
+ pairs.append((tool, outcome))
236
+ return pairs
237
+
238
+
239
+ def summarise(pairs: list[tuple[str, str]]) -> Reading:
240
+ """The window's state: degraded if it holds an `ADVERSE` outcome, else active.
241
+
242
+ The record the line NAMES is the most recent adverse one when degraded, and simply
243
+ the most recent one when active. Naming the last record while degraded would hide the
244
+ fault behind whatever happened to run after it.
245
+ """
246
+ window = pairs[-WINDOW:]
247
+ if not window:
248
+ return _unknown(EMPTY)
249
+ problems = [pair for pair in window if pair[1] in ADVERSE]
250
+ tool, outcome = problems[-1] if problems else window[-1]
251
+ return Reading(
252
+ state="degraded" if problems else "active",
253
+ events=len(window),
254
+ problems=len(problems),
255
+ tool=tool,
256
+ outcome=outcome,
257
+ )
258
+
259
+
260
+ def probe(
261
+ env: dict[str, str], store: str | None = None, start: str | None = None
262
+ ) -> Reading:
263
+ """Resolve the log the same way the server would, read it, and summarise it.
264
+
265
+ `resolve_event_log_path` is reused rather than reimplemented so there is ONE
266
+ vocabulary for `BANTAMKIT_EVENT_LOG` in this runtime and not a second copy that can
267
+ drift. It DESIGNATES a store path without creating one, so drawing a status bar never
268
+ brings a memory store into existence.
269
+ """
270
+ try:
271
+ path = resolve_event_log_path(env, store=store, start=start)
272
+ except Exception: # noqa: BLE001 - a redraw path renders, it does not raise
273
+ return _unknown(UNREADABLE)
274
+ if path is None:
275
+ return _unknown(OFF)
276
+ try:
277
+ data = read_tail(path)
278
+ except FileNotFoundError:
279
+ return _unknown(ABSENT)
280
+ except OSError:
281
+ return _unknown(UNREADABLE)
282
+ return summarise(parse(data))
283
+
284
+
285
+ def render(reading: Reading) -> str:
286
+ """The one line, without its newline. Never empty, never multi-line.
287
+
288
+ An empty line would make the host's bar flicker between one row and none, which is
289
+ worse than any wording; that is why the unknown state has a rendering at all.
290
+ """
291
+ if reading.state == "unknown":
292
+ return f"{UNKNOWN}{SEP}{reading.reason}"
293
+ events = f"{reading.events} event" + ("" if reading.events == 1 else "s")
294
+ named = f"{reading.tool} {reading.outcome}"
295
+ if reading.state == "degraded":
296
+ problems = f"{reading.problems} problem" + ("" if reading.problems == 1 else "s")
297
+ return f"{DEGRADED}{SEP}{events}{SEP}{problems}{SEP}{named}"
298
+ return f"{ACTIVE}{SEP}{events}{SEP}{named}"
299
+
300
+
301
+ def status_line(
302
+ env: dict[str, str], store: str | None = None, start: str | None = None
303
+ ) -> str:
304
+ """The total entry point: a string for every input, including inputs that are wrong.
305
+
306
+ The `except Exception` is not laziness, it is the contract. This runs on somebody's
307
+ redraw path with no operator watching; the failure this must not have is a traceback
308
+ where a status bar should be.
309
+ """
310
+ try:
311
+ return render(probe(env, store=store, start=start))
312
+ except Exception: # noqa: BLE001 - see the docstring
313
+ return f"{UNKNOWN}{SEP}{SURPRISE}"
@@ -0,0 +1,125 @@
1
+ """JSON-Schema-enforced output: validate, retry with a pointed error, bounded budget."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import jsonschema
6
+
7
+ from bantamkit.agent import Agent, _supports_response_format, response_format_for
8
+ from bantamkit.client import BantamError, Message, ModelClient
9
+ from bantamkit.contract import (
10
+ extract_json,
11
+ json_answer_retry,
12
+ parse_error_message,
13
+ schema_instruction,
14
+ schema_retry_feedback,
15
+ validation_error_message,
16
+ )
17
+ from bantamkit.profile import default as profile_default
18
+
19
+ __all__ = ["JsonAnswerGate", "StructuredOutputError", "extract_json", "structured"]
20
+
21
+
22
+ class StructuredOutputError(BantamError):
23
+ """No schema-valid output within the retry budget.
24
+
25
+ Carries the transcript up to the raise, same payload and same reason as
26
+ `MaxTurnsExceeded`: when a schema gate gives up it raises from inside the
27
+ agent's post-hook chain, and without a slot for the transcript every
28
+ `schema-exhausted` run wrote `{"output": null, "messages": []}` — the runs
29
+ most worth diagnosing recording nothing. The agent fills the slot in
30
+ (`Agent.run`); a raiser that already has the transcript may pass it here.
31
+ """
32
+
33
+ def __init__(self, message: str, messages: list[Message] | None = None):
34
+ super().__init__(message)
35
+ self.messages: list[Message] = list(messages or [])
36
+
37
+
38
+ def structured(
39
+ client: ModelClient, prompt: str, schema: dict, *, max_retries: int | None = None
40
+ ) -> dict:
41
+ """Tier 1 constrains the decode, tiers 2-3 (instruction + validate/retry) still gate.
42
+
43
+ A constrained server makes the retries rare; a server that ignores or rejects
44
+ `response_format` changes nothing, because enforcement is only ever as good as
45
+ the server and validation is what actually decides.
46
+ """
47
+ if max_retries is None:
48
+ max_retries = profile_default("structured", "max_retries")
49
+ response_format = response_format_for(schema)
50
+ messages = [
51
+ Message(role="system", content=schema_instruction(schema)),
52
+ Message(role="user", content=prompt),
53
+ ]
54
+ error = "no attempts made"
55
+ for _ in range(max_retries):
56
+ # Re-checked per attempt: a 400 on attempt 1 silently drops the tier from here on.
57
+ if _supports_response_format(client):
58
+ resp = client.chat(messages, response_format=response_format)
59
+ else:
60
+ resp = client.chat(messages)
61
+ content = resp.message.content or ""
62
+ try:
63
+ data = extract_json(content)
64
+ jsonschema.validate(data, schema)
65
+ return data
66
+ except ValueError as e:
67
+ error = parse_error_message(e)
68
+ except jsonschema.ValidationError as e:
69
+ where = "/".join(str(p) for p in e.absolute_path) or "root"
70
+ error = validation_error_message(where, e.message)
71
+ messages.append(resp.message)
72
+ messages.append(Message(role="user", content=schema_retry_feedback(error)))
73
+ raise StructuredOutputError(
74
+ f"no valid output after {max_retries} attempts; last error: {error}"
75
+ )
76
+
77
+
78
+ class JsonAnswerGate:
79
+ """Rescue the failure the 7b transcripts actually showed: right content, no JSON.
80
+
81
+ Fires only when the final answer has no extractable JSON at all. A parseable
82
+ answer that violates a schema is `SchemaGate`'s business; a parseable answer
83
+ that is simply wrong is the scorer's. This gate exists for the measured
84
+ mechanism — the model detours, answers in fluent prose, and the "ONLY this
85
+ JSON" instruction is a turn too far away — and it asks for exactly one
86
+ restatement.
87
+
88
+ Fail-open by construction: once the budget is spent the answer passes through
89
+ untouched. A gate that raised here would convert a scorable wrong answer into
90
+ an exception, and scoring must stay the judge.
91
+
92
+ `max_attempts` counts retries the gate may spend (default 1 = one restatement),
93
+ unlike `SchemaGate`, whose budget counts answers including the last rejected one.
94
+ """
95
+
96
+ def __init__(self, max_attempts: int | None = None):
97
+ self.max_attempts = (
98
+ max_attempts
99
+ if max_attempts is not None
100
+ else profile_default("json_answer", "max_attempts")
101
+ )
102
+ self.retries_used = 0
103
+ self._attempts = 0
104
+
105
+ def setup(self, agent: Agent) -> None:
106
+ self.retries_used = 0
107
+ self._attempts = 0
108
+ agent.add_post_hook(self)
109
+
110
+ def __call__(self, task: str, output: str) -> str | None:
111
+ # The budget is latched for the whole run (reset only in setup): when a
112
+ # later hook (e.g. a critique gate) keeps the run alive past this gate's
113
+ # fail-open, the budget must not re-arm and interleave retries with the
114
+ # critic's feedback.
115
+ try:
116
+ extract_json(output)
117
+ except ValueError:
118
+ pass
119
+ else:
120
+ return None
121
+ self._attempts += 1
122
+ if self._attempts > self.max_attempts:
123
+ return None
124
+ self.retries_used += 1
125
+ return json_answer_retry()
bantamkit/textutil.py ADDED
@@ -0,0 +1,30 @@
1
+ """Byte-budget truncation shared by the core loop and contract rendering."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ def truncate_counted(text: str, budget: int) -> tuple[str, int]:
7
+ """`truncate`, plus the number of bytes it dropped. Zero when nothing was cut.
8
+
9
+ C-6, 2026-08-19. The cut was already announced, but IN BAND — inside the string
10
+ handed to the model, as `[truncated N bytes]`. The number was known here and thrown
11
+ away, so nothing outside the model's own context could tell a run whose observations
12
+ were cut from one whose were not. RB-P51's shape: a run that silently proceeded is
13
+ not the same run as one that reported what it dropped.
14
+
15
+ The second return value is what makes it a column. It is deliberately NOT parsed back
16
+ out of the marker by a caller: that would be a second transcription of one rule and a
17
+ check over the two would be a tautology (RB-P47).
18
+ """
19
+ raw = text.encode()
20
+ if len(raw) <= budget:
21
+ return text, 0
22
+ dropped = len(raw) - budget
23
+ kept = raw[:budget].decode(errors="ignore")
24
+ return f"{kept}\n[truncated {dropped} bytes]", dropped
25
+
26
+
27
+ def truncate(text: str, budget: int) -> str:
28
+ """Byte-identical to what it always returned. Callers that want the count call
29
+ `truncate_counted`; `contract.py` and `filegraph.py` do not and are unchanged."""
30
+ return truncate_counted(text, budget)[0]
@@ -0,0 +1,207 @@
1
+ Metadata-Version: 2.5
2
+ Name: bantamkit
3
+ Version: 0.27.0
4
+ Summary: bantamweight tooling — harness primitives that lift small-model agents
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.11
7
+ Requires-Dist: httpx>=0.27
8
+ Requires-Dist: jsonschema>=4.21
9
+ Requires-Dist: pyyaml>=6.0
10
+ Provides-Extra: dev
11
+ Requires-Dist: hatchling>=1.24; extra == 'dev'
12
+ Requires-Dist: pytest>=8.0; extra == 'dev'
13
+ Requires-Dist: ruff>=0.4; extra == 'dev'
14
+ Provides-Extra: mcp
15
+ Requires-Dist: mcp<3,>=2.0; extra == 'mcp'
16
+ Description-Content-Type: text/markdown
17
+
18
+ # bantamkit
19
+
20
+ Harness primitives that lift small-model agents: a per-person memory store, JSON
21
+ validation, shift-work accounting, a document reader, and a skill-catalogue
22
+ auditor — served to any MCP host over stdio.
23
+
24
+ This is the **Python** distribution. There is a second, independent
25
+ implementation of the same surface on npm as
26
+ [`bantamkit-mcp`](https://www.npmjs.com/package/bantamkit-mcp), written in pure
27
+ Node. The two share a memory store on disk and are held to the same answers by a
28
+ conformance suite, so you can install whichever one your host makes easy. What
29
+ the two do *not* agree on is written down in
30
+ [`docs/porting.md`](https://github.com/Ink01101011/bantamkit/blob/main/docs/porting.md)
31
+ and summarised at the bottom of this page.
32
+
33
+ ## Install and run
34
+
35
+ ```bash
36
+ pipx run --spec "bantamkit[mcp]" bantamkit-mcp --assets-root
37
+ ```
38
+
39
+ or into an environment you keep:
40
+
41
+ ```bash
42
+ pip install "bantamkit[mcp]"
43
+ bantamkit-mcp --help
44
+ ```
45
+
46
+ The `[mcp]` extra pulls the MCP SDK. Without it you still get the library and the
47
+ operator CLI, but not the server.
48
+
49
+ The uv equivalent is `uvx --from "bantamkit[mcp]" bantamkit-mcp`. uv is not
50
+ installed on the machine this README was measured on, so unlike every other
51
+ command here that one is the documented form rather than a measured one.
52
+
53
+ ### Connect it to a host
54
+
55
+ Every host below runs the same command; only the file and the key around it change. If
56
+ you installed with `pip` into an environment you keep, replace the `command`/`args` pair
57
+ with the absolute path to the `bantamkit-mcp` console script in that environment.
58
+
59
+ **Claude Code** — one command, no file to edit. `-s user` makes it available in every
60
+ project; drop it for this project only.
61
+
62
+ ```bash
63
+ claude mcp add bantamkit -s user -- pipx run --spec "bantamkit[mcp]" bantamkit-mcp
64
+ ```
65
+
66
+ **Claude Desktop** — `~/Library/Application Support/Claude/claude_desktop_config.json`
67
+ on macOS, `%APPDATA%\Claude\claude_desktop_config.json` on Windows. Top-level key
68
+ `mcpServers`; each entry takes `command`, `args` and an optional `env`.
69
+
70
+ ```json
71
+ {"mcpServers": {"bantamkit": {"command": "pipx", "args": ["run", "--spec", "bantamkit[mcp]", "bantamkit-mcp"]}}}
72
+ ```
73
+
74
+ **GitHub Copilot in VS Code** — `.vscode/mcp.json` for one workspace, or the user
75
+ profile via the **MCP: Open User Configuration** command. Note the top-level key is
76
+ `servers`, not `mcpServers`.
77
+
78
+ ```json
79
+ {"servers": {"bantamkit": {"type": "stdio", "command": "pipx", "args": ["run", "--spec", "bantamkit[mcp]", "bantamkit-mcp"]}}}
80
+ ```
81
+
82
+ **Cursor** — `.cursor/mcp.json` in the project, or `~/.cursor/mcp.json` globally. Back
83
+ to `mcpServers`.
84
+
85
+ ```json
86
+ {"mcpServers": {"bantamkit": {"command": "pipx", "args": ["run", "--spec", "bantamkit[mcp]", "bantamkit-mcp"]}}}
87
+ ```
88
+
89
+ **Anything else that speaks MCP over stdio** runs the console script and talks JSON-RPC
90
+ on its stdin and stdout. Nothing about this package is host-specific.
91
+
92
+ The Claude Code and Claude Desktop forms were taken from this machine — `claude mcp add
93
+ --help` and an existing config file. The VS Code and Cursor forms are from those
94
+ projects' own documentation, not from a host installed here.
95
+
96
+ Point `--start` at a directory to choose where project-store discovery begins, or
97
+ `--store` at a single path to disable layering entirely. Do not reach for
98
+ `--store` by reflex: the default is layered, and the layering is most of the
99
+ value.
100
+
101
+ ## What it serves
102
+
103
+ Eleven tools, measured off a wheel installed into an empty virtualenv:
104
+
105
+ | tool | what it does |
106
+ |---|---|
107
+ | `memory_save` | store one durable fact, deduped and budgeted |
108
+ | `memory_recall` | retrieve facts matching a query |
109
+ | `memory_compact` | archive the stalest facts to fit the index budget |
110
+ | `validate_json` | validate a document against a JSON Schema |
111
+ | `bantamkit_read` | read a document — text, office formats, pdf |
112
+ | `skill_audit` | audit a skill catalogue for findings |
113
+ | `shiftwork_clock_in` | open a unit of work and get its brief |
114
+ | `shiftwork_clock_out` | close a unit with status and accounting |
115
+ | `shiftwork_status` | report the open cursor |
116
+ | `bantamkit_status` | report store health against its budget |
117
+ | `build_identity` | report the fingerprint of the source on disk |
118
+
119
+ `build_identity` describes the tree on disk, not the code currently executing —
120
+ useful precisely when a machine carries two installs under one name.
121
+
122
+ ## Requirements
123
+
124
+ Python 3.11 or newer. Three runtime dependencies — `httpx`, `jsonschema`,
125
+ `pyyaml` — plus `mcp` under the `[mcp]` extra.
126
+
127
+ ## The asset pack
128
+
129
+ Contracts, schemas, eval tasks, rubrics and tool manifests ship inside the
130
+ package and are located at import time:
131
+
132
+ ```bash
133
+ bantamkit-mcp --assets-root
134
+ ```
135
+
136
+ It prints the resolved directory and its file count. A build that cannot find the
137
+ pack fails rather than producing an artifact without it — that refusal is
138
+ deliberate, because the silent version of it shipped once.
139
+
140
+ `BANTAMKIT_ASSETS` overrides the location.
141
+
142
+ ## The operator CLI
143
+
144
+ Memory-store maintenance is a separate surface from the agent-facing tools, and
145
+ it is not served over MCP:
146
+
147
+ ```bash
148
+ python -m bantamkit.memory status # index size, budget, headroom, archives
149
+ python -m bantamkit.memory lint # exit 1 if malformed or over budget
150
+ python -m bantamkit.memory compact # archive the stalest facts
151
+ python -m bantamkit.memory archive <name>
152
+ python -m bantamkit.memory restore <name>
153
+ ```
154
+
155
+ `archive` moves a fact out of the store without deleting it; `restore` brings it
156
+ back by name.
157
+
158
+ ## Sharing a store with the Node server
159
+
160
+ Both distributions read and write the same on-disk format, so one store can be
161
+ served by either. That is also why a surface present in one and absent from the
162
+ other is not merely a coverage gap — it is a way for two servers to disagree
163
+ about one person's data. Every feature lands in both implementations in the same
164
+ change, and a conformance case compares the two answers before it counts as
165
+ ported.
166
+
167
+ ## Where the two implementations differ, on purpose
168
+
169
+ - **This side reads pdf, `.doc` and `.rtf`; the Node side refuses them by name.**
170
+ PDF is read by a stdlib reader written for this project; real OLE2 `.doc` and
171
+ `.rtf` go through `/usr/bin/textutil`, a macOS built-in that is probed at every
172
+ call and refused by name where it is absent.
173
+ - **The operator CLI is spelled differently**, and it shows in help text and
174
+ error messages: `python -m bantamkit.memory` here against `bantamkit-memory`
175
+ there. There is no third spelling — a pure-npm install has no Python in it, and
176
+ CPython does not install that console script.
177
+ - **`build_id` hashes the executing tree**, and the two runtimes are two trees, so
178
+ it differs by construction. `assets_digest` is identical, and that is the one
179
+ that carries meaning.
180
+
181
+ Each of these is recorded in the divergence table with a conformance case pinning
182
+ the wording, so the difference cannot drift unnoticed.
183
+
184
+ ## Development
185
+
186
+ ```bash
187
+ git clone https://github.com/Ink01101011/bantamkit
188
+ cd bantamkit
189
+ python -m venv .venv && .venv/bin/pip install -e "runtime-py[dev,mcp]"
190
+ .venv/bin/python -m pytest runtime-py/tests -q
191
+ .venv/bin/ruff check runtime-py
192
+ ```
193
+
194
+ The cross-runtime gate needs Node:
195
+
196
+ ```bash
197
+ node tools/conformance/run.mjs --all
198
+ ```
199
+
200
+ ## Links
201
+
202
+ - Source: https://github.com/Ink01101011/bantamkit
203
+ - The Node distribution: https://www.npmjs.com/package/bantamkit-mcp
204
+ - Install notes: [`docs/install.md`](https://github.com/Ink01101011/bantamkit/blob/main/docs/install.md)
205
+ - What the two runtimes disagree about: [`docs/porting.md`](https://github.com/Ink01101011/bantamkit/blob/main/docs/porting.md)
206
+
207
+ MIT.