v0.4.12: the type is what's enforced, at every door
What's Changed
- Tool inputs are typechecked against their
ToolContractparams TYPE, at the executor — so the model's path,POST /tools/:nameandinvokeToolare all covered by one validator - #57 — a vault write refuses a name the backend cannot store (#57)
- #58 — settings changes are observable on the notification bus (#58)
- #59 — the Telegram frontend renders files as attachments (#59)
Full Changelog: v0.4.11...v0.4.12
What it's for
A tool declares its call contract as a ToolContract arm — a real TypeScript type — and until now
nothing enforced it. The model's arguments were checked against inputSchema, a lossy projection of
that type, and only on the model's path. 0.4.12 makes the type itself the thing enforced, at the one seam
every caller passes through.
Validation moved off the toolcall hook and onto the executor. The hook channel is unchanged and
still the place to reject a call — triggers still use it — but it is a runner channel, so a
validator installed there guarded the model while POST /tools/:name and invokeTool called
tool.executor.execute directly and fired no hooks at all. Core now consults an optional
ToolCallValidator service at the executor, wrapped once per tool at registration: one check instead
of one per door, and nothing to drift.
Core's own contract is additive — with no validator registered, nothing changes. What changes is the
behaviour of an install that loads one, in two deliberate ways:
- An unknown property is rejected, as TypeScript rejects one on a fresh object literal, which is
exactly what a model-authored params object is. This is the commonest tool-call hallucination made
invisible:sessionIdmis-sent assession_idwas silently dropped, and the tool then ran believing
no session was given rather than reporting a typo.enforce: 'warn'logs one release of "Would reject"
to find such callers first. - Internal calls are validated too. A statically-typed call site should already be sound, so this is
partly waste — butinvokeToolwith a dynamic name, a trigger'sinvoke.params(typedobject) and a
compiled skill's payload are all internal callers no compiler has checked.
For a multi-action tool this says what a JSON Schema cannot: session_action's schema requires only
action, so every per-arm requirement was lost in the projection. One validator now dispatches on the
discriminant, and a missing field is reported against the arm the caller actually selected.
Errors name the field as a caller would write it, and show what was sent:
Invalid input for tool "about_matbot": .x: never (no value is valid), actual value `{"x":8}`
Two new plugins carry it: tool-types emits a pure-JS validator per tool from the checker's
resolved type, inside the pass that already builds the dts, and registers no validator service — a code
generator loading it for dts() alone never silently starts having its calls rejected. ts-validation
consumes that supply, applies enforce: off | warn | reject (default reject, since loading the plugin
is the opt-in) and registers the service core consults. json-validation moves onto the same seam, so
schema checking covers every door too, and the two compose along the line the type system already draws:
typed contracts first, loose inputSchema behind.
The three issues
#57 — a vault key that was accepted and then lost. The default vault persists to .env, so a secret
stored as email:70de70:password was written, dropped by whatever read the file back (Ignoring invalid environment assignment), and the failure surfaced a boot later as a secret that had ceased to exist. The
caller is usually an LLM inventing a name, with no way to know the rule. VaultSpec gains an optional
unstorableKey(name) — what is storable is the backend's business — and every backend's writeSecret
now throws InvalidSecretNameError carrying the rejected key and the backend's rule, phrased as what IS
storable. Removal still skips the check, so a name predating the rule stays deletable.
#58 — settings changes announced nothing. Every reader had to choose between re-reading the store on
each use (a disk read, on the filesystem backend) and caching with unbounded staleness. ts-validation
made it concrete: validation at the executor read enforce on every tool call through every door, to
re-learn a value that changes approximately never — and there was no correct cache to write, event
invalidation being impossible and a TTL a guess. Settings writes now publish an ItemChange carrying the
writing principal, since an override is per-principal.
ItemChange also gained a key — the caller's key, where the medium derives id from it. A
settings document's id is its plugin package name slugged to fit the store's id rule, and another backend
could hash or truncate, so a consumer routing on identity had to reproduce a transformation it does not
own: a copy that keeps compiling after the rule changes and simply stops matching. Silent, and
indistinguishable from "nothing changed" — the opposite of what an invalidation is for.
#59 — Telegram swallowed files. The file pipeline event was dropped, so a file-producing tool looked
like it had done nothing: the model says "here is the chart" and the chat showed only that sentence. The
event is a durable handle rather than bytes on the wire, so the frontend now pulls it and uploads it — an
image inline, audio as a clip, anything else as a document — ahead of the turn's prose. telegram_send
takes a files list too. A method Telegram rejects retries as sendDocument, because a photo's
width+height sum and a container it will not transcode are only knowable server-side, and a file that
arrives beats one that renders inline.