v0.4.15: a runaway stopped, and one version per release
What's Changed
- A runaway
tool_functionno longer freezes the daemon — a body doing more than 10s of synchronous work before its firstawaitis stopped with an error naming the likely runaway loop, and the process log names the function that was stopped. Set the limit withfunction_timeout_msinmatbot.yaml(#73) FunctionRunner(new, optionalMatbotServicesmember) — a host-supplied compiler whose synchronous execution is bounded. The CLI registers anode:vmone; absent, bodies run directly as before- Cancelling a turn stops a looping
tool_function—invokeToolrefuses to start a tool on an aborted signal, andrunFunctionstops waiting when its call is aborted tool_functionpackages — one TypeScript module whose exported functions become<package>__<function>tools, with private helpers (#63)- Validation names the key a call should not have —
background({ action: 'cancel', id })reports.action: unexpected property, not.prompt: required property missing - The boot banner warns about duplicate singleton copies, not about version numbers that are allowed to differ
- frontend-web tells the model about
POST /tools/<name>(#72) - Release tooling: one release version, a web bundle that must match its sources, and
publish-allas the only way to publish (#71, #73)
Full Changelog: v0.4.14...v0.4.15
⚠️ Worth knowing before upgrading
No breaking change to the plugin contract. Three behaviour changes a caller could notice:
invokeToolthrows ifopts.signalhas already aborted, as it already did for an unknown tool name. A caller that deliberately invoked tools after cancelling its own signal now gets an error instead.function-tools' exportedPackageFncalling convention changed. A compiled package now calls the chosen export itself, sobuildPackageFn's result takes(tool, toolInContext, context, exportName, arg).exportFnhides this, and nothing outside function-tools is known to call either directly.- A
tool_functionbody in the CLI runs under a time limit. A legitimate body that computes for more than 10s without awaiting is now stopped. Raisefunction_timeout_ms, or yield occasionally withawait.
A runaway loop, and what stops it
On 15 September a model-authored lambda froze the daemon for 260 seconds — every session, the web UI, Telegram, schedules. Its coupon-date loop never advanced (d = addMonths(matISO, 0) === d ? d : … always chose d), and it ended only because the array it kept growing hit JavaScript's length limit. A loop that did not allocate would have run until someone restarted the service.
The cause is structural, not one bad lambda: a tool_function body runs on the one event loop everything shares, and nothing in-process can interrupt synchronous code — an abort signal included, since stopping needs the loop to yield.
What stops it now. The CLI registers a FunctionRunner over node:vm. Every tool_function call — a lambda, a defined function, a package export — enters through a timed script, so a body that computes for longer than the limit before it first awaits is stopped:
Stopped after 10s of synchronous work without an await — most likely a loop that never ends.
A function may compute between awaits, but not for this long.
The caller receives that as the tool's error, and the daemon's log records which function it was:
[function-tools] stopped tool_function lambda (session fd58ab72-…, call 928d6d9b-…): Stopped after 2s of synchronous work …
Definition: `(args: { outlay: number }): Promise<number> { let d = args.outlay; while (d > 0) { d = d; } return d; }`
Awaited work never counts. A body that spends minutes on slow tool calls or HTTP requests is untouched; the clock covers only the stretch from a call's start to its first await. A tool_function started from inside another's first stretch counts against both.
function_timeout_ms: 30000 # milliseconds; default 10000function_timeout_ms: 0 registers no runner at all — bodies run unbounded, exactly as before — and warns at boot. It exists for testing.
What it does not cover, and why. A loop that runs after an await is not caught. The design that catches it — a context per run in microtaskMode: 'afterEvaluate', with each continuation drained inside a timed evaluation — works, and was built and tested. It was withdrawn because a timeout landing during that drain aborts the whole process whenever async hooks are enabled (nodejs/node#38503, closed as stale without a fix). matbot enables none, but the test runner does and any instrumentation might, and a guard that can kill the daemon is worse than the freeze it prevents. The reasoning is recorded beside the runner so it is not rebuilt unknowingly.
Cancellation. Two gaps closed alongside: runFunction never watched its call's abort signal, so cancelling a turn waited on the body regardless; and invokeTool passed the signal on without checking it, so a body looping over await tool.x() kept calling tools after the abort. A cancelled call now ends at once, and its body's next tool call throws.
The seam. FunctionRunner is an optional MatbotServices member, like MediaStore: seeded by the host, replaceable by a plugin, reverting to the host's on unload. The browser registers none — it cannot interrupt synchronous code — and neither does an embedder that has not chosen to, so both keep today's behaviour. A run stopped at the limit rejects with code: FUNCTION_TIMEOUT, exported from plugin-api and core, so a consumer can tell a stopped runaway from an ordinary failure without matching message text.
tool_function packages
tool_function { action: 'package' } defines one TypeScript module whose exported functions become tools named <package>__<function>, while its helpers, types and constants stay private and are never registered. A package is stateless — its top level is evaluated afresh on every call — so a top-level let/var, class, enum, import, bare statement or top-level await is refused, pointing the author at a plugin.
That refusal was found bypassable in review: it looked for where a statement starts, which punctuation cannot tell once semicolons are optional. const LIMIT = 10\nlet count = 0 and function f() {}\n(async () => {…})() both got through. It now walks each allowed declaration to where it ends — a function or interface at its body's }, a const or type at a top-level ; or where ASI would end it — and requires the next thing to be another. export function f<T>(…) now reports that the export is generic, rather than that it isn't a function.
Validation that names the wrong key
Calling one tool with another's shape — background({ action: 'cancel', id }), where cancel belongs to every_action — reported .prompt: required property missing, which sent the model to fix its arguments instead of its choice of tool. The typed validator now reports a key that no arm of a discriminant-less union declares as unexpected property, before trying the arms; the schema validator lists undeclared keys first when it is already refusing a call (and still never refuses one for an undeclared key alone, the schema admitting them).
One release version
The harness — core, plugin-api and the apps — always carries the release version, and so does every package being released: anything whose current contents npm does not already have. Unchanged packages keep their number. changeset version cannot express this, so pnpm version-packages now runs it and then publish.mjs --align, which moves versions and rebuilds the web bundle.
publish.mjs enforces it on every PR and before every publish: VERSIONS (a harness or releasing package off the release version), WEB BUNDLE (a committed dist/ differing from a fresh assemble — the bundle bakes every package's source and version), STALE / BEHIND (contents compared with npm's tarballs, not just version numbers), and GUARD: every package now carries a prepublishOnly that refuses a bare pnpm publish or changeset publish, so pnpm publish-all is the only way to publish. A real publish always requires a clean tree, and the unchecked publish-app script is gone.
Other fixes
- The boot banner's "version skew" warning compared version numbers, so it fired on every release that bumped the CLI without core, while missing two physical copies that happened to share a version. It now compares the resolved directories of core and plugin-api, as reached from the CLI and each of its dependencies, and names the copies.
- frontend-web contributes system context describing the relative
POST /tools/<name>andPOST /stream/tools/<name>entry points, so a model writing a live dashboard knows it can call tools over HTTP. ts-validationdeclares its@matatbread/matbot-corepeer dependency on npm; the fix had landed in source after 0.4.12 was published.
Testing
488 tests pass, including the function runner (10: a loop is stopped and the event loop is free again, a slow await is untouched, a package export and a nested run are bounded, abort ends a call and its tool calls, the timeout is logged by name). plugin-api, core, function-tools and the CLI typecheck clean; publish-check is green; both web bundles are rebuilt.
Verified against running servers booted from scratch configs: the lambda from the incident is stopped at the limit instead of running 260s; a runaway sent over POST /tools/tool_function stopped at 10.1s with no key and 2.0s with function_timeout_ms: 2000, with the named log line; 0 logs its warning at boot; a normal lambda works in all three. A stopped run, including a nested one, leaves the process alive with async hooks enabled.