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/mcpserver.py
ADDED
|
@@ -0,0 +1,1334 @@
|
|
|
1
|
+
"""bantamkit as an MCP server: memory + validation over stdio, one instance per person."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import asyncio
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import platform
|
|
11
|
+
import sys
|
|
12
|
+
from collections.abc import Callable, Iterator
|
|
13
|
+
from contextlib import contextmanager
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from importlib import metadata
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Annotated, Any
|
|
18
|
+
|
|
19
|
+
import bantamkit
|
|
20
|
+
from bantamkit import __version__, docread, shiftwork, skillaudit
|
|
21
|
+
from bantamkit.assets import AssetNotFound, assets_root, load_skill, load_tool_asset
|
|
22
|
+
from bantamkit.client import BantamError
|
|
23
|
+
from bantamkit.contract import (
|
|
24
|
+
bantamkit_read_unknown_part,
|
|
25
|
+
document_error,
|
|
26
|
+
document_manifest,
|
|
27
|
+
document_offset_past_end,
|
|
28
|
+
document_page,
|
|
29
|
+
schema_error,
|
|
30
|
+
schema_retry_feedback,
|
|
31
|
+
tool_failed,
|
|
32
|
+
)
|
|
33
|
+
from bantamkit.eventlog import EventLog
|
|
34
|
+
from bantamkit.mcpreport import build_report as build_mcp_report
|
|
35
|
+
from bantamkit.mcpreport import resolve_event_log_path
|
|
36
|
+
from bantamkit.memory import DEFAULT_INDEX_BUDGET, Memory
|
|
37
|
+
from bantamkit.statusline import status_line
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
from mcp.server import MCPServer
|
|
41
|
+
from mcp.server.mcpserver.exceptions import ResourceError
|
|
42
|
+
from mcp.server.mcpserver.tools import Tool as SDKTool
|
|
43
|
+
from mcp.server.mcpserver.utilities.func_metadata import FuncMetadata
|
|
44
|
+
from pydantic import Field
|
|
45
|
+
except ImportError: # surfaced as a clear SystemExit in main()
|
|
46
|
+
MCPServer = None # type: ignore[assignment]
|
|
47
|
+
Field = None # type: ignore[assignment]
|
|
48
|
+
FuncMetadata = object # type: ignore[assignment,misc]
|
|
49
|
+
ResourceError = None # type: ignore[assignment]
|
|
50
|
+
SDKTool = None # type: ignore[assignment]
|
|
51
|
+
|
|
52
|
+
_INSTALL_HINT = 'bantamkit-mcp needs the MCP extra: pip install "bantamkit[mcp]"'
|
|
53
|
+
|
|
54
|
+
SERVER_NAME = "bantamkit"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _version() -> str:
|
|
58
|
+
"""The version this checkout declares — never the one the last install recorded.
|
|
59
|
+
|
|
60
|
+
RB-P45: `metadata.version("bantamkit")` reads the installed dist-info, and a *stale*
|
|
61
|
+
editable install is not a `PackageNotFoundError`, so the old body could not fall back
|
|
62
|
+
— it returned a confidently wrong number. `__version__` is the same bytes hatchling
|
|
63
|
+
builds the wheel from, is already imported by the time this module exists, and has no
|
|
64
|
+
failure mode to fall back from.
|
|
65
|
+
"""
|
|
66
|
+
return __version__
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class _Undetermined(Exception):
|
|
70
|
+
"""A build fact that could not be derived, carrying WHY. Never becomes a value."""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _unavailable(reason: str) -> dict[str, str]:
|
|
74
|
+
"""The shape an underivable fact takes on the wire.
|
|
75
|
+
|
|
76
|
+
`RB-P51`'s rule is that "could not determine" must never read as a value. A sentinel
|
|
77
|
+
string (`""`, `"unknown"`, `"none"`) is the defect that rule names: it sits in a
|
|
78
|
+
field typed as a path or a digest and a caller comparing fields reads it as one. An
|
|
79
|
+
object cannot — every derivable field here is a string, an int or a bool, so a caller
|
|
80
|
+
that got a dict knows it got no answer, and the key it must read is the reason.
|
|
81
|
+
|
|
82
|
+
What this does NOT buy, said plainly: two servers that both failed to derive the same
|
|
83
|
+
fact carry the same object and compare equal on it. Unavailability is visible, not
|
|
84
|
+
discriminating. That is why `build_id` refuses to exist at all when one of its inputs
|
|
85
|
+
is unavailable, instead of hashing the failure text into something that looks like an
|
|
86
|
+
identity and agrees with every other failure.
|
|
87
|
+
"""
|
|
88
|
+
return {"unavailable": reason}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _tree_digest(root: Path, files: list[Path]) -> str:
|
|
92
|
+
"""sha256 over (relative path, size, bytes) of every file, in sorted path order.
|
|
93
|
+
|
|
94
|
+
Paths are folded in so that moving a file is a different build, and sizes so that a
|
|
95
|
+
concatenation cannot be re-partitioned into the same stream. The digest is over the
|
|
96
|
+
CONTENT and its layout under `root` — never over `root` itself — which is the whole
|
|
97
|
+
reason two installs of one build at two different paths fingerprint identically while
|
|
98
|
+
one edited line anywhere does not.
|
|
99
|
+
"""
|
|
100
|
+
running = hashlib.sha256()
|
|
101
|
+
for path in files:
|
|
102
|
+
payload = path.read_bytes()
|
|
103
|
+
running.update(path.relative_to(root).as_posix().encode("utf-8"))
|
|
104
|
+
running.update(b"\0%d\0" % len(payload))
|
|
105
|
+
running.update(payload)
|
|
106
|
+
return "sha256:" + running.hexdigest()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _code_fingerprint() -> tuple[str, int, Path]:
|
|
110
|
+
"""Fingerprint the source the interpreter imported this package from.
|
|
111
|
+
|
|
112
|
+
`bantamkit.__file__` is the same resolution the import system already performed, so
|
|
113
|
+
this reads the tree that is actually serving the call — not a checkout that happens
|
|
114
|
+
to be nearby, which is exactly the confusion `RB-P55` names inside a worktree.
|
|
115
|
+
|
|
116
|
+
TWO EXCLUSIONS, AND THE SECOND WAS MEASURED RATHER THAN REASONED. `__pycache__`
|
|
117
|
+
because bytecode is derived, and a build must not fingerprint differently before and
|
|
118
|
+
after its first import. `<package>/assets/` because THE ASSET PACK LIVES INSIDE THE
|
|
119
|
+
PACKAGE IN A WHEEL and beside the repository root in a checkout (`RB-P85`,
|
|
120
|
+
`docs/install.md`, `assets_root()`), while carrying eleven `.py` files of its own
|
|
121
|
+
(`assets/evals/devteam/repo/`): a walk that swept them in counted 22 files from an
|
|
122
|
+
editable checkout and 33 from a wheel-shaped install OF IDENTICAL CONTENT, and
|
|
123
|
+
reported the two as different builds. That is the false positive this whole surface
|
|
124
|
+
exists to avoid — a machine's user-scope wheel and project-scope editable install of
|
|
125
|
+
one commit are ONE build. The pack is fingerprinted in full by `_assets_fingerprint`,
|
|
126
|
+
so nothing goes unmeasured; it is measured once, under the field that names it.
|
|
127
|
+
"""
|
|
128
|
+
located = getattr(bantamkit, "__file__", None)
|
|
129
|
+
if not located:
|
|
130
|
+
raise _Undetermined("bantamkit has no __file__; the running code is not on disk")
|
|
131
|
+
root = Path(located).resolve().parent
|
|
132
|
+
packed_assets = root / "assets"
|
|
133
|
+
files = sorted(
|
|
134
|
+
p
|
|
135
|
+
for p in root.rglob("*.py")
|
|
136
|
+
if "__pycache__" not in p.parts and not p.is_relative_to(packed_assets)
|
|
137
|
+
)
|
|
138
|
+
if not files:
|
|
139
|
+
# A digest over nothing is a constant, and two servers that both computed one
|
|
140
|
+
# would agree — the vacuity this whole surface exists to prevent.
|
|
141
|
+
raise _Undetermined(f"no .py source found under {root}")
|
|
142
|
+
try:
|
|
143
|
+
return _tree_digest(root, files), len(files), root
|
|
144
|
+
except OSError as exc:
|
|
145
|
+
raise _Undetermined(f"unreadable source under {root}: {exc}") from None
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _assets_fingerprint() -> tuple[str, int, Path]:
|
|
149
|
+
"""Fingerprint the asset pack this build would load.
|
|
150
|
+
|
|
151
|
+
Every file, not just the ones some loader knows about: `assets_root()` is a directory
|
|
152
|
+
the server reads at call time, and a pack carrying an extra or an edited file is a
|
|
153
|
+
different pack whether or not today's code opens it. `contracts/default.yaml` is the
|
|
154
|
+
reason — `RB-P84` measured it as the one asset that differed between two live builds
|
|
155
|
+
while no resource template exposed it, so it was invisible on every probed surface.
|
|
156
|
+
"""
|
|
157
|
+
try:
|
|
158
|
+
root = assets_root()
|
|
159
|
+
except AssetNotFound as exc:
|
|
160
|
+
raise _Undetermined(f"assets_root() could not resolve a pack: {exc}") from None
|
|
161
|
+
files = sorted(p for p in root.rglob("*") if p.is_file())
|
|
162
|
+
if not files:
|
|
163
|
+
raise _Undetermined(f"asset pack at {root} contains no files")
|
|
164
|
+
try:
|
|
165
|
+
return _tree_digest(root, files), len(files), root
|
|
166
|
+
except OSError as exc:
|
|
167
|
+
raise _Undetermined(f"unreadable asset under {root}: {exc}") from None
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def build_identity() -> dict[str, Any]:
|
|
171
|
+
"""What this build can honestly say about itself, over the protocol.
|
|
172
|
+
|
|
173
|
+
`RB-P84`: two `bantamkit` endpoints are registered under one name on a developer
|
|
174
|
+
machine, both are spawned, and their advertised surfaces were byte-identical over
|
|
175
|
+
6864 bytes. `tools/mcpdrift/mcpdrift.py` closes that from OUTSIDE — it tells a person
|
|
176
|
+
two endpoints differ. This closes the other half: an agent holding a tool result can
|
|
177
|
+
ask which build produced it, mid-call, without reading the server's filesystem.
|
|
178
|
+
|
|
179
|
+
THE FIELD THAT IS THE ANSWER IS `build_id`, and it is deliberately computed from
|
|
180
|
+
content alone — server name, declared version, code digest, asset digest. `version`
|
|
181
|
+
is echoed but is not identity: it moves on release bumps, it has already lied in this
|
|
182
|
+
program (`RB-P45`, a `v0.25.0` checkout advertising `0.3.0`), and refreshing a pinned
|
|
183
|
+
install from HEAD leaves both endpoints reading one number while one keeps drifting
|
|
184
|
+
forward. `package_path`, `assets_root` and `interpreter` are LOCATION, reported
|
|
185
|
+
because they are what a person acts on, and kept out of `build_id` because two
|
|
186
|
+
installs of one build at two paths are one build.
|
|
187
|
+
|
|
188
|
+
WHAT IS REFUSED, and it is a refusal rather than a gap: no commit. See `git_commit`.
|
|
189
|
+
|
|
190
|
+
WHAT THIS DOES NOT COVER, stated so the gap is visible rather than implied: the
|
|
191
|
+
dependency tree. `mcp_sdk_version` is reported for the one SDK that shapes every
|
|
192
|
+
answer on this wire, and nothing else is — two builds whose `pydantic` differs
|
|
193
|
+
fingerprint identically here. `tools/mcpdrift/mcpdrift.py`'s `recall_bad_arg_type`
|
|
194
|
+
probe is the surface that catches that, and it is a person's instrument, not this one.
|
|
195
|
+
"""
|
|
196
|
+
identity: dict[str, Any] = {"server_name": SERVER_NAME, "version": _version()}
|
|
197
|
+
|
|
198
|
+
try:
|
|
199
|
+
digest, count, root = _code_fingerprint()
|
|
200
|
+
identity["code_digest"] = digest
|
|
201
|
+
identity["code_files"] = count
|
|
202
|
+
identity["package_path"] = str(root)
|
|
203
|
+
except _Undetermined as exc:
|
|
204
|
+
for field_name in ("code_digest", "code_files", "package_path"):
|
|
205
|
+
identity[field_name] = _unavailable(str(exc))
|
|
206
|
+
|
|
207
|
+
try:
|
|
208
|
+
digest, count, root = _assets_fingerprint()
|
|
209
|
+
identity["assets_digest"] = digest
|
|
210
|
+
identity["assets_files"] = count
|
|
211
|
+
identity["assets_root"] = str(root)
|
|
212
|
+
except _Undetermined as exc:
|
|
213
|
+
for field_name in ("assets_digest", "assets_files", "assets_root"):
|
|
214
|
+
identity[field_name] = _unavailable(str(exc))
|
|
215
|
+
|
|
216
|
+
# Location, never identity: `BANTAMKIT_ASSETS` redirects the pack, so whether the
|
|
217
|
+
# root was chosen by the operator or resolved by the package is a fact a caller
|
|
218
|
+
# comparing two endpoints needs. A bool, so it is never confusable with a missing one.
|
|
219
|
+
identity["assets_root_from_env"] = bool(os.environ.get("BANTAMKIT_ASSETS"))
|
|
220
|
+
|
|
221
|
+
identity["git_commit"] = _unavailable(
|
|
222
|
+
"refused, not missing. An installed wheel carries no repository at all, and "
|
|
223
|
+
"reading a checkout's HEAD would describe the TREE rather than the bytes that "
|
|
224
|
+
"were imported — an edited working copy serves different code under an unchanged "
|
|
225
|
+
"sha, which is precisely the confusion RB-P84 filed. A commit reported that way "
|
|
226
|
+
"would be a value that is sometimes a lie; `code_digest` is derived from the "
|
|
227
|
+
"bytes themselves and cannot be."
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
identity["interpreter"] = sys.executable or _unavailable(
|
|
231
|
+
"sys.executable is empty; this interpreter cannot name its own binary"
|
|
232
|
+
)
|
|
233
|
+
identity["python_version"] = platform.python_version()
|
|
234
|
+
identity["python_implementation"] = platform.python_implementation()
|
|
235
|
+
try:
|
|
236
|
+
# The SDK's OWN version, from the only source there is for a third-party dist.
|
|
237
|
+
# This is NOT a rollback of RB-P45: bantamkit's version still comes from
|
|
238
|
+
# `__version__` (see `_version`), this number is never used for it, and it is
|
|
239
|
+
# kept OUT of `build_id` for the same reason RB-P45 gives — an editable install's
|
|
240
|
+
# dist-info can be stale, so it is reported as environment, not as identity.
|
|
241
|
+
identity["mcp_sdk_version"] = metadata.version("mcp")
|
|
242
|
+
except metadata.PackageNotFoundError:
|
|
243
|
+
identity["mcp_sdk_version"] = _unavailable(
|
|
244
|
+
"no installed distribution metadata for `mcp`; the SDK is importable but "
|
|
245
|
+
"not pip-recorded, so its version is not knowable here"
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
identity_inputs = ("server_name", "version", "code_digest", "assets_digest")
|
|
249
|
+
inputs = {key: identity[key] for key in identity_inputs}
|
|
250
|
+
missing = sorted(key for key, value in inputs.items() if isinstance(value, dict))
|
|
251
|
+
if missing:
|
|
252
|
+
identity["build_id"] = _unavailable(
|
|
253
|
+
"computed from server_name, version, code_digest and assets_digest; could "
|
|
254
|
+
f"not derive {', '.join(missing)}. A build_id short of an input would agree "
|
|
255
|
+
"with every other build that lost the same input, so none is reported."
|
|
256
|
+
)
|
|
257
|
+
else:
|
|
258
|
+
canonical = json.dumps(inputs, sort_keys=True, separators=(",", ":"))
|
|
259
|
+
identity["build_id"] = "sha256:" + hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
|
260
|
+
|
|
261
|
+
identity["unavailable"] = sorted(
|
|
262
|
+
key
|
|
263
|
+
for key, value in identity.items()
|
|
264
|
+
if isinstance(value, dict) and "unavailable" in value
|
|
265
|
+
)
|
|
266
|
+
return identity
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
# ---------------------------------------------------------------------------------------
|
|
270
|
+
# `bantamkit_status`, the degraded footer, and the conditions both are built from.
|
|
271
|
+
#
|
|
272
|
+
# WHY A TOOL RESULT AND NOT A NOTIFICATION, MEASURED BEFORE ANY OF THIS WAS WRITTEN.
|
|
273
|
+
# The request this answers is "let me SEE that the server is alive", and the obvious
|
|
274
|
+
# implementation — push a line at the host — does not exist. Claude Code's own binary
|
|
275
|
+
# carries the sentence `modern protocol revision with no unsolicited notification path`,
|
|
276
|
+
# and its proprietary `claude/channel` capability is gated six ways, one of which is
|
|
277
|
+
# `provider !== "firstParty"` — which rules out Copilot on its own. bantamkit's host log
|
|
278
|
+
# says the same thing from the running side: `"protocolEra":"modern"` in every
|
|
279
|
+
# `Connection established` record and ten `Channel notifications skipped: server did not
|
|
280
|
+
# declare claude/channel capability`. So the only surface every host is guaranteed to
|
|
281
|
+
# render is a TOOL RESULT, and that is what all three pieces here are.
|
|
282
|
+
#
|
|
283
|
+
# THE THREE PIECES AND WHO EACH ONE IS FOR:
|
|
284
|
+
# * `bantamkit_status` — a tool. The MODEL can call it, so an agent mid-conversation can
|
|
285
|
+
# answer "is this thing working" without the operator leaving the transcript.
|
|
286
|
+
# * a same-named PROMPT — `prompts/list` was empty on both runtimes. A prompt is what a
|
|
287
|
+
# PERSON invokes. The operator asking whether their server is alive is the person.
|
|
288
|
+
# * the FOOTER — one line appended to the OTHER tools' results, and only when something
|
|
289
|
+
# is wrong. Never on a healthy call: a footer on every result is noise, noise trains
|
|
290
|
+
# the reader to stop reading, and the one time it matters it is then invisible.
|
|
291
|
+
#
|
|
292
|
+
# `docs/status.md` is the contract. Both runtimes emit these bytes.
|
|
293
|
+
# ---------------------------------------------------------------------------------------
|
|
294
|
+
|
|
295
|
+
#: The tool AND the prompt answer to this one name. Deliberately the same word: the
|
|
296
|
+
#: operator who read `bantamkit_status` in a footer must find it in their prompt menu
|
|
297
|
+
#: without translating, and a model that read it in a prompt must find the tool.
|
|
298
|
+
STATUS_NAME = "bantamkit_status"
|
|
299
|
+
|
|
300
|
+
#: What the prompt says after the report. The report is the payload; this is the one line
|
|
301
|
+
#: that tells the model what the person wanted with it, and it asks for a RELAY rather
|
|
302
|
+
#: than an interpretation — the operator invoked this to read the server's own words.
|
|
303
|
+
STATUS_PROMPT_TAIL = (
|
|
304
|
+
"Show me that report as it stands. If it says Degraded, tell me which of the problems "
|
|
305
|
+
"above you would deal with first and why; if it says Active, say so in one line and "
|
|
306
|
+
"stop."
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
#: The prompt and resource-template counts `status_report` prints, named here because the
|
|
310
|
+
#: SDK offers no public count of either and reaching into its managers is exactly the
|
|
311
|
+
#: private-attribute habit `_from_manifest` exists to have ended. They are PINNED AGAINST
|
|
312
|
+
#: THE WIRE by `test_status_surface.py`, which drives a real `prompts/list` and
|
|
313
|
+
#: `resources/templates/list` and fails if a registration is added or removed without
|
|
314
|
+
#: moving these — so they are a declaration, not a guess.
|
|
315
|
+
SERVED_PROMPTS = 1
|
|
316
|
+
SERVED_RESOURCE_TEMPLATES = 2
|
|
317
|
+
|
|
318
|
+
#: Percent of the index budget that has to be SPENT before the store is called degraded.
|
|
319
|
+
#:
|
|
320
|
+
#: 90 and not 100 because the useful moment is before the refusal, not after it: at 100%
|
|
321
|
+
#: the next `memory_save` has already failed and the operator has already seen the error.
|
|
322
|
+
#: An INTEGER percent, compared by cross-multiplication below, so the two runtimes cannot
|
|
323
|
+
#: land on opposite sides of the line through a float they rounded differently.
|
|
324
|
+
INDEX_PRESSURE_PERCENT = 90
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
@dataclass(frozen=True)
|
|
328
|
+
class Condition:
|
|
329
|
+
"""One thing that is wrong, carried in the two forms the two surfaces need.
|
|
330
|
+
|
|
331
|
+
`key` is an ASCII token from a closed set, which is the ONLY form that may reach the
|
|
332
|
+
event log (`docs/eventlog.md`'s metadata-only rule); nothing here writes one today,
|
|
333
|
+
and the field exists so that a future record cannot be tempted to log the prose.
|
|
334
|
+
`sentence` is what a person reads. It never carries a tool argument, a memory body, a
|
|
335
|
+
validated output, or a grant NAME — a layer's KIND is reportable, its name is not.
|
|
336
|
+
"""
|
|
337
|
+
|
|
338
|
+
key: str
|
|
339
|
+
sentence: str
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _plural(count: int, word: str) -> str:
|
|
343
|
+
"""`1 prompt` / `2 prompts`. Spelled once so the two runtimes cannot disagree twice."""
|
|
344
|
+
return f"{count} {word}" if count == 1 else f"{count} {word}s"
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _index_bytes(memory: Memory) -> int | None:
|
|
348
|
+
"""The size of the writable store's `index.md` on disk, or `None` if it has none.
|
|
349
|
+
|
|
350
|
+
THE STAT, NOT THE PARSE, and the difference is the reason this can run on every tool
|
|
351
|
+
call. `Memory.index_accounting()` re-derives the index by reading every fact file —
|
|
352
|
+
the docstring there says so, and the event log only calls it when a log is actually
|
|
353
|
+
enabled. A footer that has to decide on EVERY call cannot pay that, so this reads the
|
|
354
|
+
rendered artefact the store itself keeps up to date (`MemoryStore._rebuild_index`
|
|
355
|
+
writes it on every save) with one `stat`.
|
|
356
|
+
|
|
357
|
+
What that trade costs, said plainly: a store whose facts were edited on disk behind
|
|
358
|
+
the server's back has a stale `index.md`, and this reports the stale size. The
|
|
359
|
+
condition is a WARNING that the budget is nearly spent, not the budget check itself —
|
|
360
|
+
`MemoryStore._check_index_budget` is still the thing that refuses a save, and it still
|
|
361
|
+
measures the parse. The two cannot disagree about a store only bantamkit has written.
|
|
362
|
+
|
|
363
|
+
ABSENT IS 0 AND UNREADABLE IS `None`, and the two are split on the errno rather than
|
|
364
|
+
collapsed. A store that has never been saved to has no `index.md` and really does
|
|
365
|
+
spend nothing of its budget; a store whose directory refuses a `stat` has an unknown
|
|
366
|
+
index, and reporting that as 0 would claim infinite headroom at exactly the moment
|
|
367
|
+
there may be none — the same slander `Memory.index_accounting` refuses to commit.
|
|
368
|
+
"""
|
|
369
|
+
try:
|
|
370
|
+
return (memory.store.root / "index.md").stat().st_size
|
|
371
|
+
except FileNotFoundError:
|
|
372
|
+
return 0
|
|
373
|
+
except OSError:
|
|
374
|
+
return None
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _asset_pack_condition() -> Condition | None:
|
|
378
|
+
"""Is the pack this build loads still there?
|
|
379
|
+
|
|
380
|
+
`build_server` raises `AssetNotFound` at startup for a tool without a manifest, so a
|
|
381
|
+
server that is RUNNING resolved its pack once. It can still lose it afterwards — an
|
|
382
|
+
upgrade that replaces the directory, a `BANTAMKIT_ASSETS` pointed at a scratch tree
|
|
383
|
+
that gets cleaned up — and the failure is quiet: the tool descriptions were read at
|
|
384
|
+
startup and keep being served, while `resources/read` and every later `load_skill`
|
|
385
|
+
have nothing behind them.
|
|
386
|
+
|
|
387
|
+
Deliberately no path in the sentence. `assets_root()` resolves to DIFFERENT paths in
|
|
388
|
+
the two runtimes by construction (`_print_assets_root` says why at length), so a path
|
|
389
|
+
here would make the one line of this surface that cannot be compared across them, to
|
|
390
|
+
buy what `--assets-root` prints on demand.
|
|
391
|
+
"""
|
|
392
|
+
try:
|
|
393
|
+
root = assets_root()
|
|
394
|
+
except AssetNotFound:
|
|
395
|
+
return Condition(
|
|
396
|
+
"asset-pack-missing",
|
|
397
|
+
"the asset pack cannot be resolved at all, so skills, rubrics and tool "
|
|
398
|
+
"descriptions have nothing behind them — reinstall the package, or point "
|
|
399
|
+
"BANTAMKIT_ASSETS at a real pack and restart the server.",
|
|
400
|
+
)
|
|
401
|
+
if not root.is_dir():
|
|
402
|
+
return Condition(
|
|
403
|
+
"asset-pack-missing",
|
|
404
|
+
"the asset pack is gone from where this server resolved it, so skills, "
|
|
405
|
+
"rubrics and tool descriptions can no longer be re-read — run "
|
|
406
|
+
"`bantamkit-mcp --assets-root` to see where it is looking, then restart.",
|
|
407
|
+
)
|
|
408
|
+
return None
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _unreadable_layer_condition(memory: Memory) -> Condition | None:
|
|
412
|
+
"""A memory layer that could not be LISTED — which is not a layer that held nothing.
|
|
413
|
+
|
|
414
|
+
This is the sharpest of the four, because it is the one whose damage is a wrong ANSWER
|
|
415
|
+
rather than a missing one: `Memory._nothing_to_report` already refuses to say "nothing
|
|
416
|
+
is saved" when a layer is unreadable, but that sentence only reaches a person who
|
|
417
|
+
happened to run an empty recall. The footer says it on every call.
|
|
418
|
+
|
|
419
|
+
KIND, NEVER NAME. A layer's label is `project`, `extra:<grant name>` or `profile`, and
|
|
420
|
+
the grant name is the operator's own words for somebody's directory. The kind is the
|
|
421
|
+
part that is reportable; the split is here rather than at the call site so there is
|
|
422
|
+
exactly one place it can be got wrong.
|
|
423
|
+
|
|
424
|
+
It reads `Memory._layers` and `Memory._fact_count` through their private names on
|
|
425
|
+
purpose. A public accessor would be the right shape and would be a change to the
|
|
426
|
+
MEMORY layer, which this unit is not authorised to make (job39 invariant 4). Nothing
|
|
427
|
+
is re-implemented: `_fact_count` is the same counter `_unreadable_layers` uses, so
|
|
428
|
+
"unreadable" means here exactly what it means to recall.
|
|
429
|
+
"""
|
|
430
|
+
unreadable = [
|
|
431
|
+
label
|
|
432
|
+
for label, store, _writable in memory._layers
|
|
433
|
+
if memory._fact_count(store.root) is None
|
|
434
|
+
]
|
|
435
|
+
if not unreadable:
|
|
436
|
+
return None
|
|
437
|
+
kinds = sorted({label.split(":", 1)[0] for label in unreadable})
|
|
438
|
+
return Condition(
|
|
439
|
+
"memory-layer-unreadable",
|
|
440
|
+
f"{_plural(len(unreadable), 'memory layer')} could not be read "
|
|
441
|
+
f"({_plural(len(kinds), 'kind')}: {', '.join(kinds)}), so an empty recall is not "
|
|
442
|
+
"evidence that nothing is saved — check that those store directories exist and "
|
|
443
|
+
"are readable.",
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def _index_pressure_condition(memory: Memory) -> Condition | None:
|
|
448
|
+
"""The index is nearly as big as the budget that has to hold it."""
|
|
449
|
+
size = _index_bytes(memory)
|
|
450
|
+
budget = memory.store.index_budget
|
|
451
|
+
if size is None or size * 100 < INDEX_PRESSURE_PERCENT * budget:
|
|
452
|
+
return None
|
|
453
|
+
return Condition(
|
|
454
|
+
"index-budget-low",
|
|
455
|
+
f"the memory index is {size} bytes of a {budget}-byte budget, so the next save "
|
|
456
|
+
"is close to being refused — archive or shorten facts with "
|
|
457
|
+
"`python -m bantamkit.memory compact`.",
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _event_log_condition(log: EventLog) -> Condition | None:
|
|
462
|
+
"""The log was asked for and a record has already been lost.
|
|
463
|
+
|
|
464
|
+
Only reachable when the operator turned it on: `write_failed` is set inside `record`'s
|
|
465
|
+
`except OSError` and a disabled log returns before any I/O. So this never fires for
|
|
466
|
+
the default configuration, which is off.
|
|
467
|
+
"""
|
|
468
|
+
if not (log.enabled and log.write_failed):
|
|
469
|
+
return None
|
|
470
|
+
return Condition(
|
|
471
|
+
"event-log-unwritable",
|
|
472
|
+
"the event log is switched on but a write to it has already failed, so tool "
|
|
473
|
+
"outcomes are going unrecorded — check the path in BANTAMKIT_EVENT_LOG and "
|
|
474
|
+
"whether its directory is writable.",
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def degraded_conditions(memory: Memory, log: EventLog) -> list[Condition]:
|
|
479
|
+
"""Everything wrong right now, worst first. Empty list means healthy.
|
|
480
|
+
|
|
481
|
+
ORDER IS SEVERITY AND IT IS LOAD-BEARING, because the footer shows the first one: a
|
|
482
|
+
pack that vanished breaks every asset-backed surface; an unreadable layer makes recall
|
|
483
|
+
ANSWER WRONGLY rather than fail; a full index refuses the next save; a broken event log
|
|
484
|
+
costs diagnostics only.
|
|
485
|
+
|
|
486
|
+
EVERY CONDITION IS OBSERVED, NOT INFERRED — no heartbeat, no timer, no last-seen
|
|
487
|
+
timestamp. Each one is a state a test can construct and then watch this report: delete
|
|
488
|
+
the pack, make a layer's `facts/` a file, build a store whose index already exceeds
|
|
489
|
+
nine tenths of its budget, point the log at an unwritable path. A condition that
|
|
490
|
+
cannot be constructed is not claimed.
|
|
491
|
+
|
|
492
|
+
THE COST, because this runs on every tool call: one `stat` for the index, one `is_dir`
|
|
493
|
+
for the pack, one `scandir` per memory layer (two to four), and a field read for the
|
|
494
|
+
log. No fact file is opened and no index is parsed.
|
|
495
|
+
"""
|
|
496
|
+
found = (
|
|
497
|
+
_asset_pack_condition(),
|
|
498
|
+
_unreadable_layer_condition(memory),
|
|
499
|
+
_index_pressure_condition(memory),
|
|
500
|
+
_event_log_condition(log),
|
|
501
|
+
)
|
|
502
|
+
return [condition for condition in found if condition is not None]
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def degraded_notice(conditions: list[Condition]) -> str:
|
|
506
|
+
"""The one line other tools' results carry when something is wrong. `""` when nothing is.
|
|
507
|
+
|
|
508
|
+
THE EMPTY STRING FOR A HEALTHY SERVER IS THE WHOLE POINT, and it is checked in a pair:
|
|
509
|
+
`test_status_surface.py` asserts the notice is present on a degraded call AND absent on
|
|
510
|
+
a healthy one, because only the pair proves it is conditional. A footer on every result
|
|
511
|
+
is noise, and noise trains a reader to skip it — at which point the one time it matters
|
|
512
|
+
it is invisible. That is what the operator asked for in their own words
|
|
513
|
+
("ร่วม footer เฉพาะตอนผิดปกติ ด้วย") and it is the property to protect.
|
|
514
|
+
|
|
515
|
+
ONE SHAPE, ALWAYS, including for a single condition — a count of 1 reads fine, and a
|
|
516
|
+
second shape is a second thing for the port to get right. The worst condition is spelt
|
|
517
|
+
out because a bare count is not actionable; the rest are a number and a pointer,
|
|
518
|
+
because this is a FOOTER on somebody else's answer and has no licence to become the
|
|
519
|
+
answer.
|
|
520
|
+
"""
|
|
521
|
+
if not conditions:
|
|
522
|
+
return ""
|
|
523
|
+
return (
|
|
524
|
+
f"⚠️ bantamkit degraded ({len(conditions)}): {conditions[0].sentence} "
|
|
525
|
+
"Call `bantamkit_status` for the full report."
|
|
526
|
+
)
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def status_report(
|
|
530
|
+
memory: Memory,
|
|
531
|
+
log: EventLog,
|
|
532
|
+
conditions: list[Condition],
|
|
533
|
+
tools: int,
|
|
534
|
+
prompts: int,
|
|
535
|
+
templates: int,
|
|
536
|
+
) -> str:
|
|
537
|
+
"""The whole answer `bantamkit_status` returns — prose, for a person, in a transcript.
|
|
538
|
+
|
|
539
|
+
THE FIRST LINE IS THE ANSWER and it is the line the operator asked for by name:
|
|
540
|
+
`bantamkit Active 🟢`, or `bantamkit Degraded 🟠` when `conditions` is non-empty. A
|
|
541
|
+
reader who stops after eight characters has still learned the thing they came for.
|
|
542
|
+
|
|
543
|
+
THE ONE FIELD THAT IS NOT COMPARABLE ACROSS RUNTIMES is `build`. It is `build_id`,
|
|
544
|
+
which is a fingerprint of the running source, and the two runtimes fingerprint two
|
|
545
|
+
different trees by construction — `docs/porting.md`'s divergence table already rules
|
|
546
|
+
exactly this for `build_identity`. It is here anyway because "which bantamkit" is half
|
|
547
|
+
the question this tool exists to answer: two endpoints registered under one name is
|
|
548
|
+
the situation RB-P84 filed, and a version string cannot tell them apart.
|
|
549
|
+
|
|
550
|
+
NO ARGUMENT VALUE CAN REACH THIS. It takes none, and every input above is server
|
|
551
|
+
state; `test_status_surface.py` asserts the absence positively with a sentinel.
|
|
552
|
+
"""
|
|
553
|
+
identity = build_identity()
|
|
554
|
+
build = identity["build_id"]
|
|
555
|
+
facts = memory._fact_count(memory.store.root)
|
|
556
|
+
size = _index_bytes(memory)
|
|
557
|
+
budget = memory.store.index_budget
|
|
558
|
+
lines = [
|
|
559
|
+
f"bantamkit {'Degraded 🟠' if conditions else 'Active 🟢'}",
|
|
560
|
+
f"version {identity['version']}, build "
|
|
561
|
+
+ (build if isinstance(build, str) else "unavailable"),
|
|
562
|
+
f"serving {_plural(tools, 'tool')}, {_plural(prompts, 'prompt')}, "
|
|
563
|
+
f"{_plural(templates, 'resource template')}",
|
|
564
|
+
"memory: "
|
|
565
|
+
+ (
|
|
566
|
+
"the project store could not be read"
|
|
567
|
+
if facts is None
|
|
568
|
+
else f"{_plural(facts, 'fact')} in the project store"
|
|
569
|
+
)
|
|
570
|
+
+ ", index "
|
|
571
|
+
+ ("unreadable" if size is None else str(size))
|
|
572
|
+
+ f" of {budget} bytes",
|
|
573
|
+
f"event log: {'on' if log.enabled else 'off'}",
|
|
574
|
+
]
|
|
575
|
+
if conditions:
|
|
576
|
+
lines.append(f"{_plural(len(conditions), 'problem')}:")
|
|
577
|
+
lines.extend(f"- {condition.sentence}" for condition in conditions)
|
|
578
|
+
return "\n".join(lines)
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
class _ArgMetadata(FuncMetadata): # type: ignore[misc,valid-type]
|
|
582
|
+
"""The SDK's argument metadata, with the JSON pre-parse switched off where Node has none.
|
|
583
|
+
|
|
584
|
+
Before validating, `FuncMetadata.pre_parse_json` runs `json.loads` over every string
|
|
585
|
+
argument whose annotation is not exactly `str` (so `str | None` and `int | None`
|
|
586
|
+
qualify) to unwrap a JSON-encoded list or object some hosts send. The Node SDK does no
|
|
587
|
+
such thing, and `bantamkit_read` is the one tool on this surface whose arguments are
|
|
588
|
+
not plain `str`, so it was the one tool whose two halves could read the same call
|
|
589
|
+
differently. Measured on the wire (job43 F2, then review round 2): a 4301-digit `part`
|
|
590
|
+
reached the model as `isError: Exceeds the limit (4300 digits) for integer string
|
|
591
|
+
conversion` — `json.loads` hit CPython's integer-string cap before the handler ran;
|
|
592
|
+
`part="null"` was unwrapped to `None` and served the MANIFEST where Node refuses an
|
|
593
|
+
unknown part named `null`; `part="[1]"` became a list and a pydantic `string_type`
|
|
594
|
+
error where Node says `no part named [1]`; `offset="null"` paged from row 0 where
|
|
595
|
+
Node's `pyargs` refuses it as `int_parsing`.
|
|
596
|
+
|
|
597
|
+
THE PROPERTY: for `bantamkit_read`, no argument is JSON-unwrapped — a string is the
|
|
598
|
+
string that was sent. Pydantic's own lax coercion stays (`offset="5"` → 5), because
|
|
599
|
+
Node's `pyargs` does the same. `unwrap_json` is `False` for that tool alone: the other
|
|
600
|
+
nine take plain `str` (plus `list`/`dict` fields the SDK unwraps by design, e.g.
|
|
601
|
+
`memory_save.links`), and for them the per-key loop below is the one change: a
|
|
602
|
+
`ValueError` the SDK's own loop would have let escape — `json.loads` on a 4301-digit
|
|
603
|
+
integer string hits CPython's digit cap — now leaves that key as the string it was, so
|
|
604
|
+
`memory_save(links="[" + "1"*4301 + "]")` is pydantic's `list_type` validation frame
|
|
605
|
+
instead of an `isError` carrying `Exceeds the limit (4300 digits)` (measured, review
|
|
606
|
+
round 3). That is the frame Node already printed, so it is a parity gain, not a
|
|
607
|
+
behaviour the nine keep.
|
|
608
|
+
"""
|
|
609
|
+
|
|
610
|
+
unwrap_json: bool = True
|
|
611
|
+
|
|
612
|
+
def pre_parse_json(self, data: dict[str, Any]) -> dict[str, Any]:
|
|
613
|
+
if not self.unwrap_json:
|
|
614
|
+
return data
|
|
615
|
+
out = data.copy()
|
|
616
|
+
for key, value in data.items():
|
|
617
|
+
try:
|
|
618
|
+
out[key] = super().pre_parse_json({key: value})[key]
|
|
619
|
+
except ValueError:
|
|
620
|
+
out[key] = value
|
|
621
|
+
return out
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
# Tools whose arguments reach the handler exactly as sent — see `_ArgMetadata`.
|
|
625
|
+
_NO_JSON_UNWRAP = frozenset({"bantamkit_read"})
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
# `assets/tools/bantamkit_read.json` `offset.maximum` — Number.MAX_SAFE_INTEGER, the largest
|
|
629
|
+
# integer a JSON parser on the Node side reads back unchanged.
|
|
630
|
+
OFFSET_MAXIMUM = 9007199254740991
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def _from_manifest(fn: Callable[..., Any], name: str) -> Any:
|
|
634
|
+
"""Bind one handler to its manifest entry — description, BOTH schemas, and the surface.
|
|
635
|
+
|
|
636
|
+
The asset pack under `assets/tools/` is the tool contract for every runtime that
|
|
637
|
+
serves this surface, so the schema has to arrive WITH the registration rather than be
|
|
638
|
+
corrected onto it afterwards. Until this function existed, `build_server` registered
|
|
639
|
+
each handler by decorator (description from a Python constant, schema derived from the
|
|
640
|
+
signature) and then reached through a private attribute of the SDK's tool manager to
|
|
641
|
+
overwrite two of the schemas after the fact. The served bytes were right, but only for
|
|
642
|
+
as long as that attribute stayed reachable under that name: an SDK that renamed it
|
|
643
|
+
would have gone back to advertising whatever the Python signature says, silently, and
|
|
644
|
+
a port to a second runtime would have had to re-read Python to learn the contract.
|
|
645
|
+
|
|
646
|
+
`MCPServer(..., tools=[...])` is the public seam. The object handed to the constructor
|
|
647
|
+
already carries the manifest's description and schema, so registration and truth are
|
|
648
|
+
one step. `from_function` still derives `fn_metadata` from the signature, and that is
|
|
649
|
+
what validates arguments at CALL time; the signatures mirror the manifest. Only what
|
|
650
|
+
is ADVERTISED changes hands here, and it now has exactly one source.
|
|
651
|
+
|
|
652
|
+
`output_schema` goes through the same seam by a different route. It is not a field on
|
|
653
|
+
the SDK's `Tool`; it is a `cached_property` returning `fn_metadata.output_schema`, and
|
|
654
|
+
`MCPServer.list_tools` copies it straight onto the wire's `outputSchema`. Putting the
|
|
655
|
+
manifest's value in the instance dict is what a `cached_property` reads first, so the
|
|
656
|
+
override lands — and it lands on the ADVERTISEMENT ONLY, because the call path
|
|
657
|
+
(`FuncMetadata.convert_result`) consults `fn_metadata`, which is untouched. That is
|
|
658
|
+
the same division `parameters` already has: the manifest says what is promised, the
|
|
659
|
+
signature still says what is enforced.
|
|
660
|
+
|
|
661
|
+
The `surfaces` check is what makes that field load-bearing. `assets/tools/` serves the
|
|
662
|
+
eval agent too, and a manifest entry that does not claim `mcp` must not become a tool
|
|
663
|
+
on this server — otherwise the field is a comment, and a port that trusts it would
|
|
664
|
+
serve a different set of tools than this runtime does.
|
|
665
|
+
"""
|
|
666
|
+
asset = load_tool_asset(name)
|
|
667
|
+
if "mcp" not in asset["surfaces"]:
|
|
668
|
+
raise BantamError(
|
|
669
|
+
f"tool asset {name!r} does not claim the mcp surface: {asset['surfaces']}"
|
|
670
|
+
)
|
|
671
|
+
tool = SDKTool.from_function(fn, name=asset["name"], description=asset["description"])
|
|
672
|
+
return tool.model_copy(
|
|
673
|
+
update={
|
|
674
|
+
"parameters": asset["parameters"],
|
|
675
|
+
"output_schema": asset["output_schema"],
|
|
676
|
+
"fn_metadata": _ArgMetadata(
|
|
677
|
+
**dict(tool.fn_metadata), unwrap_json=name not in _NO_JSON_UNWRAP
|
|
678
|
+
),
|
|
679
|
+
}
|
|
680
|
+
)
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
@contextmanager
|
|
684
|
+
def _record_raise(log: EventLog, tool: str) -> Iterator[None]:
|
|
685
|
+
"""Record the TYPE of anything that escapes, then let it escape unchanged.
|
|
686
|
+
|
|
687
|
+
The host already logs that a tool failed and how long it took; what it cannot say is
|
|
688
|
+
which exception the component threw — and the one place it tried, it leaked an
|
|
689
|
+
argument value doing it (`eventlog.py`'s docstring, measured). So: `type(exc).__name__`
|
|
690
|
+
and nothing else, via `EventLog.raised`, which has no other input available to it.
|
|
691
|
+
|
|
692
|
+
`BaseException` rather than `Exception` deliberately. A `KeyboardInterrupt` or a
|
|
693
|
+
`SystemExit` out of a handler is exactly the shape whose cause is hardest to
|
|
694
|
+
reconstruct afterwards, the record costs one line, and the `raise` is unconditional
|
|
695
|
+
— nothing here decides whether the tool fails, only whether the failure was written
|
|
696
|
+
down. `EventLog.record` swallows its own `OSError`, so this cannot mask the original.
|
|
697
|
+
"""
|
|
698
|
+
try:
|
|
699
|
+
yield
|
|
700
|
+
except BaseException as exc:
|
|
701
|
+
log.raised(tool, exc)
|
|
702
|
+
raise
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def _record_result(log: EventLog, tool: str, call: Callable[[], dict[str, Any]]) -> dict[str, Any]:
|
|
706
|
+
"""Run a shiftwork handler and record its `result` — the register's OWN verdict.
|
|
707
|
+
|
|
708
|
+
Every `shiftwork` entry point answers a dict whose `result` key is the decision it
|
|
709
|
+
made: `brief`, `escalate`, `success`, `ok`, `status`, `error`. `clock_in` returning
|
|
710
|
+
`escalate` is the sharp case — the register's whole stop-and-ask contract, invisible
|
|
711
|
+
in the host's log because the tool call succeeded. Reading the key is reading the
|
|
712
|
+
decision; nothing here inspects a `reason` string, which is prose and can carry a
|
|
713
|
+
checkpoint path.
|
|
714
|
+
|
|
715
|
+
`raised` is the outcome for an ESCAPING EXCEPTION and is spelled differently from
|
|
716
|
+
`error` on purpose: `result: "error"` is a refusal the register composed and returned
|
|
717
|
+
normally, and collapsing the two would lose the only distinction between a checkpoint
|
|
718
|
+
that was rejected and a handler that fell over.
|
|
719
|
+
"""
|
|
720
|
+
with _record_raise(log, tool):
|
|
721
|
+
answer = call()
|
|
722
|
+
log.record(tool, str(answer.get("result", "unknown")))
|
|
723
|
+
return answer
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
def build_server(memory: Memory, log: EventLog | None = None) -> Any:
|
|
727
|
+
"""Assemble the MCP server around one Memory instance (the per-person state).
|
|
728
|
+
|
|
729
|
+
`log` is the event-log sink (`docs/eventlog.md`), resolved from `BANTAMKIT_EVENT_LOG`
|
|
730
|
+
when the caller does not supply one and DISABLED unless that variable asks for it.
|
|
731
|
+
It is a parameter and not only an environment read so that a test can inject a fixed
|
|
732
|
+
clock and a scratch path without setting a process-wide variable — the same seam
|
|
733
|
+
`runtime-ts` needs for its half of the conformance case.
|
|
734
|
+
|
|
735
|
+
EVERY HANDLER BELOW RECORDS FROM A DECISION, NEVER FROM ITS REPLY. `memory_save`
|
|
736
|
+
reads `SaveOutcome.status`, `memory_recall` reads `RecallOutcome.status`, the three
|
|
737
|
+
shiftwork tools read the register's own `result` key, `validate_json` reads the
|
|
738
|
+
`valid` bool it is about to return, and `build_identity` reads the length of the
|
|
739
|
+
`unavailable` list it computed. Not one of them looks at the words. That is the
|
|
740
|
+
property `test_eventlog.py::test_the_record_does_not_move_when_the_reply_wording_
|
|
741
|
+
does` holds: change a reply's wording and the record must be byte-identical.
|
|
742
|
+
|
|
743
|
+
THE DEGRADED FOOTER IS APPLIED AFTER THE RECORD, EVERY TIME. A footer is a rendering
|
|
744
|
+
decision about somebody else's answer; the record is the decision the component made.
|
|
745
|
+
Folding one into the other would put a filesystem observation into a line a
|
|
746
|
+
conformance case byte-compares, and would make the log move when nothing the tool did
|
|
747
|
+
moved. `_noted` and `_noted_dict` below therefore run last, on the way out.
|
|
748
|
+
"""
|
|
749
|
+
if MCPServer is None:
|
|
750
|
+
raise SystemExit(_INSTALL_HINT)
|
|
751
|
+
if log is None:
|
|
752
|
+
log = EventLog.from_env(memory.store.root)
|
|
753
|
+
|
|
754
|
+
def _notice() -> str:
|
|
755
|
+
"""The footer for right now — recomputed per call, never cached.
|
|
756
|
+
|
|
757
|
+
A cache would be the one thing that could make this lie: a condition that cleared
|
|
758
|
+
(or arrived) between two calls has to be visible on the next one, and the whole
|
|
759
|
+
probe is a handful of `stat`s. It is also what keeps two servers over one store
|
|
760
|
+
from disagreeing about the state of it.
|
|
761
|
+
"""
|
|
762
|
+
return degraded_notice(degraded_conditions(memory, log))
|
|
763
|
+
|
|
764
|
+
def _noted(reply: str) -> str:
|
|
765
|
+
"""A prose reply, plus the footer if there is one. Byte-identical when healthy."""
|
|
766
|
+
notice = _notice()
|
|
767
|
+
return f"{reply}\n\n{notice}" if notice else reply
|
|
768
|
+
|
|
769
|
+
def _noted_dict(answer: dict[str, Any]) -> dict[str, Any]:
|
|
770
|
+
"""A structured reply, plus the footer if there is one, under one reserved key.
|
|
771
|
+
|
|
772
|
+
A JSON result has no margin to write in: the rendered text of these tools IS the
|
|
773
|
+
serialised object, so a sentence appended to it would stop being parseable. The
|
|
774
|
+
footer therefore arrives as `bantamkit_degraded`, LAST in the key order and only
|
|
775
|
+
when it exists — every one of these tools advertises
|
|
776
|
+
`additionalProperties: true`, so a key that comes and goes is inside the contract
|
|
777
|
+
it already declares. A healthy call is byte-identical to what it was before this
|
|
778
|
+
surface existed, which is the same guarantee `_noted` gives for prose.
|
|
779
|
+
"""
|
|
780
|
+
notice = _notice()
|
|
781
|
+
return {**answer, "bantamkit_degraded": notice} if notice else answer
|
|
782
|
+
|
|
783
|
+
def memory_save(
|
|
784
|
+
type: str, name: str, description: str, body: str, links: list[str] | None = None
|
|
785
|
+
) -> str:
|
|
786
|
+
with _record_raise(log, "memory_save"):
|
|
787
|
+
outcome = memory.save_outcome(type, name, description, body, links)
|
|
788
|
+
if log.enabled:
|
|
789
|
+
# Only when a log is actually on: `index_accounting` re-parses `facts/`, and
|
|
790
|
+
# a diagnostic must not put that on the path of an operator who did not ask
|
|
791
|
+
# for one. Headroom is left to the reader rather than stored — it is
|
|
792
|
+
# `budget - index_bytes`, and a derived field is a second thing to keep true.
|
|
793
|
+
index_bytes, budget = memory.index_accounting()
|
|
794
|
+
detail: dict[str, Any] = {"budget": budget}
|
|
795
|
+
if index_bytes is not None:
|
|
796
|
+
detail["index_bytes"] = index_bytes
|
|
797
|
+
log.record("memory_save", outcome.status, detail)
|
|
798
|
+
return _noted(outcome.reply)
|
|
799
|
+
|
|
800
|
+
def memory_recall(query: str, k: int | None = None) -> str:
|
|
801
|
+
if k is not None:
|
|
802
|
+
k = max(1, min(k, 5)) # the advertised schema's bounds; clients may ignore it
|
|
803
|
+
with _record_raise(log, "memory_recall"):
|
|
804
|
+
outcome = memory.recall_outcome(query, k)
|
|
805
|
+
detail = {
|
|
806
|
+
"budget": outcome.budget,
|
|
807
|
+
"candidates": outcome.candidates,
|
|
808
|
+
"layers": outcome.layers,
|
|
809
|
+
"reached": outcome.reached,
|
|
810
|
+
"returned": outcome.returned,
|
|
811
|
+
"unreadable": outcome.unreadable,
|
|
812
|
+
}
|
|
813
|
+
if outcome.source is not None:
|
|
814
|
+
detail["source"] = outcome.source
|
|
815
|
+
log.record("memory_recall", outcome.status, detail)
|
|
816
|
+
return _noted(outcome.reply)
|
|
817
|
+
|
|
818
|
+
def memory_compact(reserve: int | None = None) -> str:
|
|
819
|
+
"""The model's half of compaction; the hook (`docs/hooks.md`) is the automatic half.
|
|
820
|
+
|
|
821
|
+
`memory_save`'s refused-budget reply names this tool, so it acts on exactly the
|
|
822
|
+
store that refused: `Memory.compact_outcome` reaches `memory.store` — the
|
|
823
|
+
writable project layer — and never a grant or the profile layer. The status is
|
|
824
|
+
the store's own decision (`archived` when the archive list is non-empty,
|
|
825
|
+
`nothing-archived` otherwise), never a match on the reply.
|
|
826
|
+
"""
|
|
827
|
+
# No floor here: `MemoryStore.compact` clamps `reserve` to `[0, budget // 2]`
|
|
828
|
+
# itself, so a negative value from a client that ignored the schema is already
|
|
829
|
+
# handled where the arithmetic lives, and a second clamp would be a second thing
|
|
830
|
+
# to keep equal across the two runtimes.
|
|
831
|
+
with _record_raise(log, "memory_compact"):
|
|
832
|
+
outcome = memory.compact_outcome(reserve)
|
|
833
|
+
log.record(
|
|
834
|
+
"memory_compact",
|
|
835
|
+
outcome.status,
|
|
836
|
+
{
|
|
837
|
+
"archived": outcome.archived,
|
|
838
|
+
"budget": outcome.budget,
|
|
839
|
+
"index_after": outcome.index_after,
|
|
840
|
+
"index_before": outcome.index_before,
|
|
841
|
+
},
|
|
842
|
+
)
|
|
843
|
+
return _noted(outcome.reply)
|
|
844
|
+
|
|
845
|
+
def validate_json(output: str, schema: dict[str, Any]) -> dict[str, Any]:
|
|
846
|
+
with _record_raise(log, "validate_json"):
|
|
847
|
+
error = schema_error(output, schema)
|
|
848
|
+
if error is None:
|
|
849
|
+
log.record("validate_json", "valid")
|
|
850
|
+
return _noted_dict({"valid": True, "feedback": None})
|
|
851
|
+
log.record("validate_json", "invalid")
|
|
852
|
+
return _noted_dict(
|
|
853
|
+
{
|
|
854
|
+
"valid": False,
|
|
855
|
+
"feedback": schema_retry_feedback(error),
|
|
856
|
+
}
|
|
857
|
+
)
|
|
858
|
+
|
|
859
|
+
def shiftwork_clock_in(checkpoint: str) -> dict[str, Any]:
|
|
860
|
+
return _noted_dict(
|
|
861
|
+
_record_result(log, "shiftwork_clock_in", lambda: shiftwork.clock_in(checkpoint))
|
|
862
|
+
)
|
|
863
|
+
|
|
864
|
+
def shiftwork_clock_out(
|
|
865
|
+
checkpoint: str,
|
|
866
|
+
unit_id: str,
|
|
867
|
+
status: str,
|
|
868
|
+
handoff_patch: dict[str, Any],
|
|
869
|
+
history_entry: dict[str, Any],
|
|
870
|
+
accounting: dict[str, Any] | None = None,
|
|
871
|
+
) -> dict[str, Any]:
|
|
872
|
+
return _noted_dict(
|
|
873
|
+
_record_result(
|
|
874
|
+
log,
|
|
875
|
+
"shiftwork_clock_out",
|
|
876
|
+
lambda: shiftwork.clock_out(
|
|
877
|
+
checkpoint, unit_id, status, handoff_patch, history_entry, accounting
|
|
878
|
+
),
|
|
879
|
+
)
|
|
880
|
+
)
|
|
881
|
+
|
|
882
|
+
def shiftwork_status(checkpoint: str) -> dict[str, Any]:
|
|
883
|
+
return _noted_dict(
|
|
884
|
+
_record_result(log, "shiftwork_status", lambda: shiftwork.status(checkpoint))
|
|
885
|
+
)
|
|
886
|
+
|
|
887
|
+
# A TOOL and not a resource or an `initialize` field, because the gap RB-P84 names is
|
|
888
|
+
# an AGENT MID-CALL: the host reads `serverInfo` once at handshake and the
|
|
889
|
+
# tool-calling model never sees it, and `resources/read` is a host-facing surface
|
|
890
|
+
# most clients never expose to the model at all. A tool is in `tools/list`, so the
|
|
891
|
+
# model that just received a `memory_recall` answer can ask who answered it.
|
|
892
|
+
def build_identity_tool() -> dict[str, Any]:
|
|
893
|
+
with _record_raise(log, "build_identity"):
|
|
894
|
+
identity = build_identity()
|
|
895
|
+
# The COUNT of underivable fields, not the fields and not the digests. A code
|
|
896
|
+
# digest is by construction different in the two runtimes — they fingerprint two
|
|
897
|
+
# different trees (`docs/porting.md`'s divergence table says so about `build_id`)
|
|
898
|
+
# — so putting one in a record that a conformance case byte-compares would make
|
|
899
|
+
# the record unportable to buy nothing the tool's own reply does not already say.
|
|
900
|
+
log.record(
|
|
901
|
+
"build_identity",
|
|
902
|
+
"partial" if identity["unavailable"] else "complete",
|
|
903
|
+
{"unavailable": len(identity["unavailable"])},
|
|
904
|
+
)
|
|
905
|
+
return _noted_dict(identity)
|
|
906
|
+
|
|
907
|
+
def bantamkit_status() -> str:
|
|
908
|
+
"""The one surface every host renders, answering "is this thing working".
|
|
909
|
+
|
|
910
|
+
NO FOOTER ON THIS ONE, and the omission is the design rather than an oversight:
|
|
911
|
+
the report already carries every condition in full, and a footer would repeat the
|
|
912
|
+
worst of them three lines below itself.
|
|
913
|
+
|
|
914
|
+
NOTHING IS RECORDED IN THE EVENT LOG EITHER. Every other handler records the
|
|
915
|
+
decision its component made; this one makes no decision — it observes. A record
|
|
916
|
+
would be a second, worse copy of a state the log's own reader can see, and its
|
|
917
|
+
`outcome` would have to be the health verdict, which moves with the filesystem
|
|
918
|
+
rather than with anything the call did. `_record_raise` still wraps the body, so
|
|
919
|
+
a handler that FALLS OVER is still written down.
|
|
920
|
+
"""
|
|
921
|
+
with _record_raise(log, "bantamkit_status"):
|
|
922
|
+
return status_report(
|
|
923
|
+
memory,
|
|
924
|
+
log,
|
|
925
|
+
degraded_conditions(memory, log),
|
|
926
|
+
len(tools),
|
|
927
|
+
SERVED_PROMPTS,
|
|
928
|
+
SERVED_RESOURCE_TEMPLATES,
|
|
929
|
+
)
|
|
930
|
+
|
|
931
|
+
def bantamkit_read(
|
|
932
|
+
path: str,
|
|
933
|
+
part: str | None = None,
|
|
934
|
+
offset: Annotated[int, Field(le=OFFSET_MAXIMUM)] | None = None,
|
|
935
|
+
limit: int | None = None,
|
|
936
|
+
) -> str:
|
|
937
|
+
"""The reader on the MCP surface (job43): `docread` digests, `contract` words it.
|
|
938
|
+
|
|
939
|
+
The eval pair (`evalrun._document_tools`) already renders a manifest, a page and
|
|
940
|
+
every refusal from these two modules, and this handler makes the SAME calls with
|
|
941
|
+
the path standing in for the document name, so the two surfaces print the same
|
|
942
|
+
bytes for the same file. Three sentences are this tool's own — the continuation
|
|
943
|
+
line names `bantamkit_read`, an unknown part is a fact about the file, and so is
|
|
944
|
+
a part with no rows (`document_error` over `"{part}" in {path} has no rows`).
|
|
945
|
+
|
|
946
|
+
THE RECORD IS A DECISION, NEVER A REPLY. `manifest` and `page` are the branch
|
|
947
|
+
taken; `refused-unreadable` is `extract` raising (a missing file, a directory, a
|
|
948
|
+
container this reader has no extractor for, an `OSError` the filesystem threw —
|
|
949
|
+
all of them reach the model as a `document_error` sentence in the reader's own
|
|
950
|
+
words, never as an exception on the wire); `refused-unknown-part` and
|
|
951
|
+
`refused-offset` are the two argument refusals — the latter also when the part has
|
|
952
|
+
no rows at all, where NO offset can be in range and the sentence says so instead of
|
|
953
|
+
"numbered 0 to -1" (review round 3). `detail` carries the container
|
|
954
|
+
kind (a token from `docread`'s closed set), the part count, and the rows and
|
|
955
|
+
UTF-8 bytes the reply carries — never the path, never a part name, never a row.
|
|
956
|
+
|
|
957
|
+
`limit` is clamped to the advertised `[1, 200]` and `offset` to `>= 0` the way
|
|
958
|
+
`memory_recall` clamps `k`: the schema says so, and a client may ignore it. The
|
|
959
|
+
one bound NOT clamped is `offset`'s `maximum` (`OFFSET_MAXIMUM`, 2**53 - 1): a
|
|
960
|
+
clamp would silently read a different row than the one asked for, and the Node
|
|
961
|
+
port cannot even carry the number — `JSON.parse` has already rounded it — so both
|
|
962
|
+
sides refuse it with the schema refusal they already share. It is bound in the
|
|
963
|
+
SIGNATURE (`Field(le=...)`), which is what `from_function` validates at call time;
|
|
964
|
+
the advertised schema still comes from the manifest alone (`_from_manifest`).
|
|
965
|
+
"""
|
|
966
|
+
start = 0 if offset is None else max(0, offset)
|
|
967
|
+
rows = docread.DEFAULT_ROW_LIMIT
|
|
968
|
+
if limit is not None:
|
|
969
|
+
rows = max(1, min(limit, docread.PAGE_MAX_ROWS))
|
|
970
|
+
with _record_raise(log, "bantamkit_read"):
|
|
971
|
+
try:
|
|
972
|
+
doc = docread.extract(path)
|
|
973
|
+
except (docread.DocumentReadError, OSError) as exc:
|
|
974
|
+
log.record("bantamkit_read", "refused-unreadable")
|
|
975
|
+
return _noted(document_error(exc))
|
|
976
|
+
detail: dict[str, Any] = {"kind": doc.kind, "parts": len(doc.parts)}
|
|
977
|
+
if part is None:
|
|
978
|
+
reply = document_manifest(
|
|
979
|
+
[
|
|
980
|
+
{
|
|
981
|
+
"document": path,
|
|
982
|
+
"kind": doc.kind,
|
|
983
|
+
"index": p.index,
|
|
984
|
+
"part": p.name,
|
|
985
|
+
"row_count": p.row_count,
|
|
986
|
+
"rows": p.rows,
|
|
987
|
+
"omissions": [o.as_dict() for o in p.omissions],
|
|
988
|
+
}
|
|
989
|
+
for p in doc.parts
|
|
990
|
+
],
|
|
991
|
+
[{"document": path, "omissions": [o.as_dict() for o in doc.omissions]}]
|
|
992
|
+
if doc.omissions
|
|
993
|
+
else [],
|
|
994
|
+
)
|
|
995
|
+
detail.update(rows=sum(p.row_count for p in doc.parts), bytes=doc.text_bytes)
|
|
996
|
+
log.record("bantamkit_read", "manifest", detail)
|
|
997
|
+
return _noted(reply)
|
|
998
|
+
try:
|
|
999
|
+
target = doc.part(part)
|
|
1000
|
+
except docread.DocumentReadError:
|
|
1001
|
+
log.record("bantamkit_read", "refused-unknown-part", detail)
|
|
1002
|
+
return _noted(bantamkit_read_unknown_part(part, path, [p.name for p in doc.parts]))
|
|
1003
|
+
if target.row_count == 0:
|
|
1004
|
+
log.record("bantamkit_read", "refused-offset", detail)
|
|
1005
|
+
return _noted(document_error(f'"{target.name}" in {path} has no rows'))
|
|
1006
|
+
if start >= target.row_count:
|
|
1007
|
+
log.record("bantamkit_read", "refused-offset", detail)
|
|
1008
|
+
return _noted(document_offset_past_end(target.name, start, target.row_count))
|
|
1009
|
+
got = docread.page(doc, part, start, rows, docread.PAGE_MAX_BYTES)
|
|
1010
|
+
detail.update(rows=len(got.rows), bytes=len(got.text.encode()))
|
|
1011
|
+
log.record("bantamkit_read", "page", detail)
|
|
1012
|
+
return _noted(
|
|
1013
|
+
document_page(
|
|
1014
|
+
document=path,
|
|
1015
|
+
part=got.part,
|
|
1016
|
+
offset=got.offset,
|
|
1017
|
+
rows=list(got.rows),
|
|
1018
|
+
row_count=got.total_rows,
|
|
1019
|
+
next_offset=got.next_offset,
|
|
1020
|
+
truncated_bytes=got.truncated_bytes,
|
|
1021
|
+
next_key="bantamkit_read_page_next",
|
|
1022
|
+
)
|
|
1023
|
+
)
|
|
1024
|
+
|
|
1025
|
+
def skill_audit(
|
|
1026
|
+
root: str,
|
|
1027
|
+
enabled: list[str] | None = None,
|
|
1028
|
+
usage: dict[str, int] | None = None,
|
|
1029
|
+
check: str | None = None,
|
|
1030
|
+
budget: int | None = None,
|
|
1031
|
+
versions: dict[str, str] | None = None,
|
|
1032
|
+
) -> str:
|
|
1033
|
+
"""The catalogue auditor on the MCP surface: `skillaudit` measures, this serves it.
|
|
1034
|
+
|
|
1035
|
+
THE REPLY IS A JSON DOCUMENT AND NOT PROSE, which is the one thing that makes this
|
|
1036
|
+
handler shaped differently from `bantamkit_read` next door. The reader answers a
|
|
1037
|
+
person reading a transcript, so `contract` words it; this answers a caller that has
|
|
1038
|
+
to compare `catalogue_bytes` against a budget it set and act on the ids in
|
|
1039
|
+
`findings[].skills`. A sentence would have to be parsed back. So `Audit.as_json` is
|
|
1040
|
+
the reply verbatim, and the only strings this layer authors are the refusals.
|
|
1041
|
+
|
|
1042
|
+
THE REFUSALS ARE ARGUMENT FAILURES, ALL THREE OF THEM, so they go through
|
|
1043
|
+
`tool_failed` — an unknown `check`, a negative `budget`, a `root` that is not a
|
|
1044
|
+
directory. Nothing about the CONTENT of the tree refuses: a file that will not
|
|
1045
|
+
decode, a block that will not parse and a plugin that is switched off are recorded
|
|
1046
|
+
as omissions and counted. An audit that refused because one of twenty-six files is
|
|
1047
|
+
malformed would have said nothing about the other twenty-five.
|
|
1048
|
+
|
|
1049
|
+
`check` defaults HERE rather than in the signature's default so that the one
|
|
1050
|
+
spelling of the default lives in `skillaudit.audit`, and `None` and an absent
|
|
1051
|
+
argument reach it as the same thing.
|
|
1052
|
+
|
|
1053
|
+
`versions` is the third caller-supplied host fact beside `enabled` and `usage`: the
|
|
1054
|
+
version directory the host actually serves, per `<plugin>@<marketplace>`. It is
|
|
1055
|
+
passed straight through, and an absent map is the byte-order fallback.
|
|
1056
|
+
|
|
1057
|
+
THE RECORD IS A DECISION, NEVER A REPLY, and it holds no free text: `audited`
|
|
1058
|
+
carries the four counts the host cannot see (skills, catalogue bytes, findings,
|
|
1059
|
+
omissions) and `refused` carries nothing at all. `root` is a path the operator
|
|
1060
|
+
typed and `findings[].skills` are the names of their skills; neither is a decision
|
|
1061
|
+
this handler made, so neither is written down.
|
|
1062
|
+
"""
|
|
1063
|
+
with _record_raise(log, "skill_audit"):
|
|
1064
|
+
try:
|
|
1065
|
+
result = skillaudit.audit(
|
|
1066
|
+
root,
|
|
1067
|
+
enabled=enabled,
|
|
1068
|
+
usage=usage,
|
|
1069
|
+
check="all" if check is None else check,
|
|
1070
|
+
budget=budget,
|
|
1071
|
+
versions=versions,
|
|
1072
|
+
)
|
|
1073
|
+
except (skillaudit.SkillAuditError, OSError) as exc:
|
|
1074
|
+
log.record("skill_audit", "refused")
|
|
1075
|
+
return _noted(tool_failed("skill_audit", exc))
|
|
1076
|
+
log.record(
|
|
1077
|
+
"skill_audit",
|
|
1078
|
+
"audited",
|
|
1079
|
+
{
|
|
1080
|
+
"skills": result.skills,
|
|
1081
|
+
"bytes": result.catalogue_bytes,
|
|
1082
|
+
"findings": len(result.findings),
|
|
1083
|
+
"omissions": len(result.omissions),
|
|
1084
|
+
},
|
|
1085
|
+
)
|
|
1086
|
+
return _noted(result.as_json())
|
|
1087
|
+
|
|
1088
|
+
# The served surface, in one place, read out of the asset pack. Adding a tool here
|
|
1089
|
+
# without an asset raises AssetNotFound at startup — the manifest cannot drift behind
|
|
1090
|
+
# the server, because the server cannot start without it.
|
|
1091
|
+
#
|
|
1092
|
+
# `bantamkit_status` went LAST rather than first, `memory_compact` after it rather
|
|
1093
|
+
# than beside `memory_save` where a reader would look for it, `bantamkit_read`
|
|
1094
|
+
# after that and `skill_audit` after that. Registration order IS the served order
|
|
1095
|
+
# (`test_tool_manifest.py::test_the_golden_records_the_order_the_wire_actually_
|
|
1096
|
+
# serves`), and appending is the only edit that leaves the others where every
|
|
1097
|
+
# existing declaration says they are.
|
|
1098
|
+
tools = [
|
|
1099
|
+
_from_manifest(memory_save, "memory_save"),
|
|
1100
|
+
_from_manifest(memory_recall, "memory_recall"),
|
|
1101
|
+
_from_manifest(validate_json, "validate_json"),
|
|
1102
|
+
_from_manifest(shiftwork_clock_in, "shiftwork_clock_in"),
|
|
1103
|
+
_from_manifest(shiftwork_clock_out, "shiftwork_clock_out"),
|
|
1104
|
+
_from_manifest(shiftwork_status, "shiftwork_status"),
|
|
1105
|
+
_from_manifest(build_identity_tool, "build_identity"),
|
|
1106
|
+
_from_manifest(bantamkit_status, "bantamkit_status"),
|
|
1107
|
+
_from_manifest(memory_compact, "memory_compact"),
|
|
1108
|
+
_from_manifest(bantamkit_read, "bantamkit_read"),
|
|
1109
|
+
_from_manifest(skill_audit, "skill_audit"),
|
|
1110
|
+
]
|
|
1111
|
+
|
|
1112
|
+
server = MCPServer(
|
|
1113
|
+
SERVER_NAME,
|
|
1114
|
+
instructions=load_skill("memory"),
|
|
1115
|
+
version=_version(),
|
|
1116
|
+
tools=tools,
|
|
1117
|
+
)
|
|
1118
|
+
|
|
1119
|
+
# THE PROMPT, AND WHY IT IS NOT A DUPLICATE OF THE TOOL ABOVE.
|
|
1120
|
+
#
|
|
1121
|
+
# `prompts/list` was EMPTY on both runtimes while both advertised `hasPrompts: true`,
|
|
1122
|
+
# so this is a new surface rather than an addition to one. A tool is what the MODEL
|
|
1123
|
+
# can call; a prompt is what a PERSON can invoke — in Claude Code it is a slash
|
|
1124
|
+
# command in the operator's own menu. The person wanting to know whether their server
|
|
1125
|
+
# is alive is the operator, and until now the only way for them to ask was to talk a
|
|
1126
|
+
# model into asking for them.
|
|
1127
|
+
#
|
|
1128
|
+
# IT CARRIES THE ANSWER, not an instruction to go and get it. `prompts/get` runs
|
|
1129
|
+
# server-side, so the report is already in the message the host inserts: the operator
|
|
1130
|
+
# sees it with no tool round trip, and it is true as of the moment they asked.
|
|
1131
|
+
@server.prompt(
|
|
1132
|
+
name=STATUS_NAME,
|
|
1133
|
+
title="bantamkit status",
|
|
1134
|
+
description=(
|
|
1135
|
+
"Is bantamkit actually working? Inserts the running server's own status "
|
|
1136
|
+
"report — active or degraded, its version and build fingerprint, the memory "
|
|
1137
|
+
"store it is bound to, whether the event log is on, and every degraded "
|
|
1138
|
+
"condition in full. Invoke it when you want the server to answer for itself "
|
|
1139
|
+
"rather than ask a model to go and check."
|
|
1140
|
+
),
|
|
1141
|
+
)
|
|
1142
|
+
def bantamkit_status_prompt() -> str:
|
|
1143
|
+
conditions = degraded_conditions(memory, log)
|
|
1144
|
+
report = status_report(
|
|
1145
|
+
memory, log, conditions, len(tools), SERVED_PROMPTS, SERVED_RESOURCE_TEMPLATES
|
|
1146
|
+
)
|
|
1147
|
+
return f"{report}\n\n{STATUS_PROMPT_TAIL}"
|
|
1148
|
+
|
|
1149
|
+
@server.resource("bantamkit://skills/{name}")
|
|
1150
|
+
def skill_resource(name: str) -> str:
|
|
1151
|
+
try:
|
|
1152
|
+
return load_skill(name)
|
|
1153
|
+
except AssetNotFound:
|
|
1154
|
+
raise ResourceError(f"unknown skill asset: {name}") from None
|
|
1155
|
+
|
|
1156
|
+
@server.resource("bantamkit://rubrics/{name}")
|
|
1157
|
+
def rubric_resource(name: str) -> str:
|
|
1158
|
+
path = assets_root() / "rubrics" / f"{name}.yaml"
|
|
1159
|
+
if not path.is_file():
|
|
1160
|
+
raise ResourceError(f"unknown rubric asset: {name}")
|
|
1161
|
+
return path.read_text(encoding="utf-8")
|
|
1162
|
+
|
|
1163
|
+
return server
|
|
1164
|
+
|
|
1165
|
+
|
|
1166
|
+
def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|
1167
|
+
parser = argparse.ArgumentParser(
|
|
1168
|
+
prog="bantamkit-mcp",
|
|
1169
|
+
description="bantamkit MCP server (stdio): per-person memory + JSON validation.",
|
|
1170
|
+
)
|
|
1171
|
+
# POSITION IS WIRE-VISIBLE. argparse prints optionals in the order they were added, so
|
|
1172
|
+
# this line -- not any format string -- decides where `[--assets-root]` sits in the
|
|
1173
|
+
# generated usage, and the Node formatter has to reproduce that. It goes FIRST, beside
|
|
1174
|
+
# the `-h` argparse adds for us, because those two share a property nothing below them
|
|
1175
|
+
# has: they print and return 0 without a memory store, a transport, or a server. Every
|
|
1176
|
+
# flag after them configures a server that is actually going to run.
|
|
1177
|
+
parser.add_argument(
|
|
1178
|
+
"--assets-root",
|
|
1179
|
+
action="store_true",
|
|
1180
|
+
help="print the resolved asset pack root and its file count, then exit",
|
|
1181
|
+
)
|
|
1182
|
+
parser.add_argument("--k", type=int, default=3, help="default recall budget (default: 3)")
|
|
1183
|
+
# The index is loaded into every prompt, so its ceiling is a deployment decision.
|
|
1184
|
+
# It had no flag: 4096 was reachable only by editing `store.py`, which made
|
|
1185
|
+
# `docs/memory.md`'s "lifecycle is an operator decision" true of the design and
|
|
1186
|
+
# false of the deployment. Lifecycle ACTIONS stay off this process — it speaks MCP
|
|
1187
|
+
# over stdout — and live on `python -m bantamkit.memory`.
|
|
1188
|
+
parser.add_argument(
|
|
1189
|
+
"--index-budget",
|
|
1190
|
+
type=int,
|
|
1191
|
+
default=DEFAULT_INDEX_BUDGET,
|
|
1192
|
+
metavar="BYTES",
|
|
1193
|
+
help=f"memory index byte budget (default: {DEFAULT_INDEX_BUDGET})",
|
|
1194
|
+
)
|
|
1195
|
+
# WHY THIS IS A FLAG ON THIS PROCESS AND NOT A NEW ENTRY POINT.
|
|
1196
|
+
# `memory/__main__.py` argues lifecycle needs a stream it owns, because this process
|
|
1197
|
+
# speaks MCP over stdout. True of a RUNNING server; not true of a flag that prints and
|
|
1198
|
+
# returns before any transport exists -- `--assets-root` above is the precedent. And it
|
|
1199
|
+
# is decisive here rather than merely convenient: `runtime-ts/package.json` declares
|
|
1200
|
+
# exactly one bin, `bantamkit-mcp`, so anything hung off `python -m ...` is unreachable
|
|
1201
|
+
# in a pure-npx install, which is the shipped product.
|
|
1202
|
+
#
|
|
1203
|
+
# POSITION IS DELIBERATE AND IT IS NOT BESIDE `-h`. `--assets-root` sits first because
|
|
1204
|
+
# it needs NOTHING; this one honours `--store`/`--start` to find the event log, so it
|
|
1205
|
+
# reads after the flags it consumes. The second reason is measured: the first line of
|
|
1206
|
+
# the wrapped usage is pinned in two places -- this repo's
|
|
1207
|
+
# `test_assets_root_appears_in_the_generated_help_in_the_documented_position` and the
|
|
1208
|
+
# `cli` conformance suite's reference precondition -- and at the 80-column fallback the
|
|
1209
|
+
# line breaks after `[--index-budget BYTES]`. Adding here leaves that line byte-identical;
|
|
1210
|
+
# adding before it would move it and turn a differential suite into a re-baselining one.
|
|
1211
|
+
parser.add_argument(
|
|
1212
|
+
"--mcp-report",
|
|
1213
|
+
action="store_true",
|
|
1214
|
+
help="print an analysis of the host MCP log joined with bantamkit's event log, then exit",
|
|
1215
|
+
)
|
|
1216
|
+
# THE SAME THREE ARGUMENTS AS `--mcp-report`, and the same place for the same reason.
|
|
1217
|
+
# It prints and returns before a transport exists; the single Node bin makes a flag the
|
|
1218
|
+
# only reachable surface in the shipped npx install, which is what a `statusLine`
|
|
1219
|
+
# registration has to name; and it honours `--store`/`--start` to find the event log, so
|
|
1220
|
+
# it belongs after the flags it consumes and before the store group. Registering it here
|
|
1221
|
+
# leaves the FIRST line of the 80-column usage -- the pinned one -- byte-identical and
|
|
1222
|
+
# grows only the second.
|
|
1223
|
+
parser.add_argument(
|
|
1224
|
+
"--statusline",
|
|
1225
|
+
action="store_true",
|
|
1226
|
+
help="print one status line for a host status bar, then exit",
|
|
1227
|
+
)
|
|
1228
|
+
stores = parser.add_mutually_exclusive_group()
|
|
1229
|
+
stores.add_argument("--store", help="single memory store path (disables layering)")
|
|
1230
|
+
stores.add_argument(
|
|
1231
|
+
"--start", help="directory to start project-store discovery from (default: cwd)"
|
|
1232
|
+
)
|
|
1233
|
+
return parser.parse_args(argv)
|
|
1234
|
+
|
|
1235
|
+
|
|
1236
|
+
def _build_memory(args: argparse.Namespace) -> Memory:
|
|
1237
|
+
if args.k < 1:
|
|
1238
|
+
raise SystemExit("--k must be >= 1")
|
|
1239
|
+
if args.index_budget < 1:
|
|
1240
|
+
raise SystemExit("--index-budget must be >= 1")
|
|
1241
|
+
if args.store is not None:
|
|
1242
|
+
if not args.store:
|
|
1243
|
+
raise SystemExit("--store requires a non-empty path")
|
|
1244
|
+
return Memory(store=args.store, k=args.k, index_budget=args.index_budget)
|
|
1245
|
+
return Memory.layered(start=args.start, k=args.k, index_budget=args.index_budget)
|
|
1246
|
+
|
|
1247
|
+
|
|
1248
|
+
def _print_mcp_report(args: argparse.Namespace) -> None:
|
|
1249
|
+
"""`--mcp-report`: the joined report on stdout, then return. No transport, no server.
|
|
1250
|
+
|
|
1251
|
+
Same discipline as `_print_assets_root`: written through `sys.stdout.buffer`, because
|
|
1252
|
+
`sys.stdout` is a text stream with newline translation and on Windows `print` would
|
|
1253
|
+
emit CRLF where Node's `process.stdout.write` emits LF.
|
|
1254
|
+
|
|
1255
|
+
NOTHING HERE IS WRITTEN. `mcpreport` opens the host's log read-only, creates no
|
|
1256
|
+
directory under its root, and `resolve_event_log_path` designates a store path without
|
|
1257
|
+
bringing a store into existence. The report is an observation, not a session.
|
|
1258
|
+
"""
|
|
1259
|
+
text = build_mcp_report(
|
|
1260
|
+
dict(os.environ),
|
|
1261
|
+
resolve_event_log_path(dict(os.environ), store=args.store, start=args.start),
|
|
1262
|
+
)
|
|
1263
|
+
sys.stdout.buffer.write(text.encode("utf-8"))
|
|
1264
|
+
sys.stdout.buffer.flush()
|
|
1265
|
+
|
|
1266
|
+
|
|
1267
|
+
def _print_status_line(args: argparse.Namespace) -> None:
|
|
1268
|
+
"""`--statusline`: exactly one line on stdout, then return. Never stderr, never a raise.
|
|
1269
|
+
|
|
1270
|
+
Written through `sys.stdout.buffer` for the same two reasons `_print_mcp_report` is:
|
|
1271
|
+
`print` would emit CRLF on Windows where Node emits LF and the conformance suite
|
|
1272
|
+
compares these bytes, and the line carries U+1F7E2 / U+1F7E0 / U+26AA, which a Windows
|
|
1273
|
+
console's default code page cannot encode -- `sys.stdout` would raise
|
|
1274
|
+
`UnicodeEncodeError` where this writes UTF-8.
|
|
1275
|
+
|
|
1276
|
+
`status_line` is total, so there is no failure arm here to write. That is the point of
|
|
1277
|
+
the surface: this runs on a host's redraw path with nobody watching, and a traceback
|
|
1278
|
+
where a status bar should be is the one output it may not produce.
|
|
1279
|
+
"""
|
|
1280
|
+
text = status_line(dict(os.environ), store=args.store, start=args.start)
|
|
1281
|
+
sys.stdout.buffer.write(f"{text}\n".encode())
|
|
1282
|
+
sys.stdout.buffer.flush()
|
|
1283
|
+
|
|
1284
|
+
|
|
1285
|
+
def _print_assets_root() -> None:
|
|
1286
|
+
"""`--assets-root`: two lines on stdout, then return -- no store, no transport, no server.
|
|
1287
|
+
|
|
1288
|
+
THE TWO RUNTIMES PRINT DIFFERENT PATHS ON PURPOSE. Do not "fix" that. Python resolves
|
|
1289
|
+
the repo-root `assets/` (or the copy packaged inside `bantamkit/`); Node resolves
|
|
1290
|
+
`runtime-ts/assets/`, which `scripts/sync-assets.mjs` vendors at prepack time. The two
|
|
1291
|
+
trees are byte-identical file for file, but they live at different paths by
|
|
1292
|
+
construction and always will, so line 1 is NOT comparable across runtimes. What IS
|
|
1293
|
+
comparable, and what the conformance case pins: exactly two lines, the shape
|
|
1294
|
+
`<root>\\n<count> files\\n`, the same COUNT on both sides, exit 0, stdout not stderr,
|
|
1295
|
+
empty stderr, and no transport started.
|
|
1296
|
+
|
|
1297
|
+
Written through `sys.stdout.buffer` rather than `print`, because `sys.stdout` is a text
|
|
1298
|
+
stream with newline translation: on Windows `print` would emit CRLF where Node's
|
|
1299
|
+
`process.stdout.write` emits LF, and a byte-comparing conformance runner would call
|
|
1300
|
+
that a divergence. UTF-8 is what Node's `Buffer.from(string)` uses, so the encoding
|
|
1301
|
+
matches too.
|
|
1302
|
+
"""
|
|
1303
|
+
root = assets_root()
|
|
1304
|
+
files = sum(1 for path in root.rglob("*") if path.is_file())
|
|
1305
|
+
sys.stdout.buffer.write(f"{root}\n{files} files\n".encode())
|
|
1306
|
+
sys.stdout.buffer.flush()
|
|
1307
|
+
|
|
1308
|
+
|
|
1309
|
+
def main() -> None:
|
|
1310
|
+
if MCPServer is None:
|
|
1311
|
+
raise SystemExit(_INSTALL_HINT)
|
|
1312
|
+
args = _parse_args()
|
|
1313
|
+
# Before `_build_memory`, which touches the filesystem, and before the server exists at
|
|
1314
|
+
# all -- the Node arm returns from `main` here too, ahead of `new RawStdioTransport()`.
|
|
1315
|
+
if args.assets_root:
|
|
1316
|
+
_print_assets_root()
|
|
1317
|
+
return
|
|
1318
|
+
if args.mcp_report:
|
|
1319
|
+
_print_mcp_report(args)
|
|
1320
|
+
return
|
|
1321
|
+
if args.statusline:
|
|
1322
|
+
_print_status_line(args)
|
|
1323
|
+
return
|
|
1324
|
+
server = build_server(_build_memory(args))
|
|
1325
|
+
asyncio.run(server.run_stdio_async())
|
|
1326
|
+
|
|
1327
|
+
|
|
1328
|
+
# `python -m bantamkit.mcpserver` is the invocation a host config reaches for when the
|
|
1329
|
+
# console script is not on PATH. Without this guard the module imported fine, defined
|
|
1330
|
+
# `main`, and exited 0 with nothing on either stream; the client saw CONNECTION_CLOSED,
|
|
1331
|
+
# which names the symptom and not the cause. Measured 2026-08-21 before this line:
|
|
1332
|
+
# `.venv/bin/python -m bantamkit.mcpserver` -> exit 0, stdout 0 bytes, stderr 0 bytes.
|
|
1333
|
+
if __name__ == "__main__":
|
|
1334
|
+
main()
|