Skip to content

Run programmatic tool calls in a closed machine

Status: accepted

Current decision

Mevedel exposes programmatic tool calling as one hybrid model tool named ToolCall. The name is model-facing prompt surface, so it states what the tool does rather than abbreviating it; the original PTC acronym survives only as the internal mevedel-ptc-* namespace and the ptc-primitives skill key. Its JSON expression argument runs in a fresh, in-process explicit continuation machine, never through host eval or macroexpand-all. Guest text is read into a private obarray. Special forms, syntax transformers, and pure primitives are closed hand-audited tables; operator resolution precedes argument evaluation, and a static preflight walks the parsed script before execution, reporting statically visible unknown operators in one error with nearest-match suggestions and validating literal regexps. Step, time, input, expansion, tool-call, operand, result, numeric, regexp, and retained-value budgets keep the guest bounded. Atomic operand accounting counts each use of shared aggregates before host execution; final-value accounting does the same before serialization. Fuel pauses return control to Emacs between slices.

The implementation keeps three ownership seams: mevedel-ptc-interpreter.el owns the pipeline-independent guest machine, mevedel-ptc-driver.el owns the nested pipeline lifecycle behind one execution entry, and mevedel-tool-ptc.el owns request roster policy, the adapter, registration, and aggregate rendering. This split keeps the security-sensitive evaluator cohesive while isolating asynchronous orchestration state from model-facing tool policy.

ToolCall has a static description; ToolSearch returns callable contracts without changing the role's native core. A provably direct call returns the underlying tool result and supported media, rendered as that tool. Settled collapsed activity summaries use that same specialist presentation name; the cached canonical child, provider name and envelope audit remain ToolCall. A composed program returns its final value with a separate child audit. A direct text result retains only a bounded preview in the redundant child audit; the outer result carries the returned value. Instruction/interaction tools may require standalone calls. Required hook context and repair feedback survive even when intermediate values are discarded.

Nested calls use the ordinary structured pipeline for validation, hooks, permissions, resource preparation, snapshots, handlers, rendering, and post-use hooks. Children carry ptc source and ENVELOPE/N identity. Provider-only delivery runs at the outer consumer. The user can inspect full child output in per-tool rows; composed programs do not put all intermediate output into provider history. Nested Reads bypass conversation duplicate suppression so hidden intermediate content cannot poison later reads or become an unusable reuse stub.

Parallel and parallel-map admit direct tool calls with bounded host concurrency and source-ordered joined results. Ordinary child failures become guest error values. Permission denial aborts composition and cancels siblings where possible; user cancellation interrupts the envelope. Earlier effects are not rolled back. Fuel/timer boundaries yield to Emacs even when children complete synchronously.

Root scripts persist an envelope before children start and on settlement; intermediate child progress stays in memory for the next ordinary autosave. The provider pipeline records settlement after output projection, committing the bounded preview and any staged full-output artifact together before delivery. An interruption before this boundary restores the last running checkpoint; post-use hooks still receive the full result before projection. Agent scripts use agent interrupted-turn recovery instead of the parent's root checkpoint. The guest machine is never serialized or resumed after restart. Runtime-generated prohibited calls fail before their dispatch, but may be constructed after earlier authorized calls; static preflight is not a transaction.

The dialect reference owns forms, primitives, regexps, limits, and composition rules. The tool manual owns pipeline delivery and the native/discoverable capability boundary.

Rationale, alternatives, and consequences

A closed in-process evaluator provides a stable orchestration boundary without letting model-authored expressions evaluate host Lisp. Operator resolution occurs before arguments, and shared aggregates are charged per use before host work and serialization. The boundary must hold even when operating-system confinement is unavailable. Eval's separately permission-gated best-effort process tradeoff does not supply that unconditional orchestration boundary.

This rejects native Elisp evaluation, host macro expansion, property-scraped primitives, a JavaScript runtime, virtual transcript rows, ToolCall-only modes, futures, resumable cells, guest notifications, cross-call storage, and guest media-emission helpers.

It also rejects the alternative of running full Elisp in a child emacs -Q --batch confined by an OS sandbox, which is what the Eval tool's batch mode does. That boundary is conditional: mevedel-sandbox-mode defaults to best-effort, so a failed Bubblewrap probe -- no bwrap, no user namespaces, macOS or Windows, or a TRAMP target that lacks it -- runs the child unconfined with a disclosure rather than refusing. A process boundary without a working sandbox isolates crashes and host data structures, not authority: the child still reads the user's credentials and reaches the network. Eval accepts that trade because it is permission-gated per call and the user chooses the mode. Orchestration cannot: scripts are authored from context that includes tool output, so their boundary has to hold on every platform without a probe. The interpreter's does. Child media references remain in the user-visible audit with payload bytes removed. Those broader mechanisms add lifecycle or trust boundaries that the measured orchestration use case does not require.

Decision history

Language and display

Two boundaries were relaxed after a profiled test session (2026-08-25) showed the model burning full turns rediscovering them: the guest regexp subset now admits unnested quantifiers (*, +, ?, \{n,m\} on one literal, escape, dot, or bracket class, capped at eight quantified atoms) while still rejecting quantified groups, stacked quantifiers, alternation, and backreferences. The work budget conservatively raises input size to the quantified-atom count, backstopping the residual polynomial adjacency case; and the pure-primitive table gained the syntactic file-name-* helpers (applied with file-name handlers disabled), take, and a fixed-comparator sort, because the model hand-rolled basename/dirname from split-string twice in one session. The audit standard is unchanged; the default answer to "is this pure string/list manipulation?" moved to yes.

The original flat child display duplicated preview, full output, and return value, and could use only one fontification mode. Separate collapsible rows delegate each child to its existing renderer while retaining one ordered envelope audit.

Discovery and agent ownership

On 2026-09-07 provider cache measurements moved ToolCall to a static description and ToolSearch-retrieved contracts. Native schema loading was removed; direct calls gained ordinary result/media semantics while composed calls retained the closed guest and audit.

The same lifecycle measurements exposed an additional avoidable prefix change: an ephemeral generic ToolSearch/ToolCall reminder preceded each worker task, then disappeared when gptel reconstructed its history. This repeated guidance already present in the native descriptions. The reminder and its durable constructor recipes are removed; discovery remains in the native tool contracts, with result-specific recovery and optional path-skill notices at their existing seams. Persisted agent templates containing the removed recipes are rejected under the project's no-compatibility policy; start fresh agents.

A subsequent discovery investigation found that elisp returned the available introspection tools while emacs found none, and task tools had no catalog summaries for queries such as plan or dependencies. ToolSearch now carries small, static capability cues, and those tools have outcome-oriented searchable summaries. This restores capability awareness without restoring per-read nudges, mandatory routing, or dynamic native schemas. It fixes demonstrated discovery gaps; improved autonomous tool selection remains a behavioral measurement, not an established consequence of these changes.

That measurement, taken on 2026-10-16 over 83 saved sessions (about 17,000 tool calls, root and agent transcripts), replaced the static capability index. About 350 of 519 ToolSearch calls were exact-name lookups of tools the model already knew: WriteStdin (84), SendMessage (97, mostly agents reporting to /root), and the agent-control tools. Each cost a model turn, and the retrieved contract stayed in history anyway, so deferral saved no context for tools in regular use. Tools the index merely named stayed unused: Imenu, Xref and Treesitter had no calls, tasks four. Eleven tools had no summary and matched only by name.

The decision now has three tiers. Tools used constantly are native for the roles that need them: WriteStdin and Monitor in the implementation preset, WriteStdin for named roles with Bash, SendMessage for every named role and for a default agent whose inherited catalog permits it. Fixed per-role schemas preserve the cache property that removed dynamic loading. Every other permitted specialist appears in a retained tool listing with its calling shape and summary, delivered like the skill catalog and rendered from the recipient's own catalog, so the model calls it through ToolCall without searching. The shape marks standalone-only tools, so skipping discovery does not hide the composition restriction. ToolSearch remains the source of full contracts and failure recovery; its broad results now return calling shapes. The capability index is deleted rather than kept beside the listing, and omitted summaries default to the description's first sentence. Whether visibility raises use of rarely called specialists is again a measurement for later sessions.

Listing every specialist made the cost of fine-grained families visible: sixteen introspection tools and five task tools were a third of the listing, and the same counts showed thirteen introspection and four task calls in total. The introspection family is now three tools (EmacsDescribe, EmacsFind, EmacsManual) whose arguments select the aspect or kind; EmacsDescribe decides its own permission per call, so a value read still always asks. Each argument keeps one meaning: EmacsFind only matches names (functions, commands, variables, libraries), while where a definition lives and whether a library is loaded are EmacsDescribe's location aspect. Feature checks and a load-path listing inside EmacsFind would have given its pattern three meanings depending on kind. Tasks are TaskWrite and TaskList. TaskWrite takes a batch in which an entry with an id updates that task and one without creates a task, rather than replacing the whole list, because several agents edit one session list by ID; the batch validates before mutating so it applies atomically. IDs are session-wide, not write authority: canonical agent owners are checked before mutation, permitting self/descendant management and root supervision, not silent sibling updates. See the agent identity decision for the ownership correction prompted by reused IDs after a sibling settled.

The original root-only restriction protected two host seams, not the evaluator. Agents lacked effective-roster handling, and their session resolution could write an envelope checkpoint into the root sidecar and later recover it into the root transcript. On 2026-08-25 agent-local callable catalogs and omission of root checkpoints for agent-owned scripts removed those restrictions. Built-in roles now declare ToolCall; their interrupted-turn recovery owns abandoned scripts.

Durability cost and preflight limits

Checkpoint durability is per script, not per nested call. The original design rewrote the sidecar through a full publication after every child transition; the profiled test session put 40 of its 58 publication generations (69%) inside six script windows, stored 3.72 MiB of publications for a 224 KiB transcript, and serialized nominally parallel children ~180-300 ms apart for 20-30 ms of actual work — and on TRAMP targets each write is a remote round trip. Now the durable writes are the start checkpoint (before any child runs) and the settled write; between them child audit progress is journaled in the in-memory session checkpoint, which any unrelated autosave captures. The cost is recovery fidelity after a crash mid-script: the child audit restores as of the last autosave rather than the last child, and the row still settles as interrupted either way.

The start checkpoint was still a full root save: transcript normalization, the prompt index and a segment publication. A measured 2026-09-29 run on a 1.4 MB session spent 1.9 s in its eight start saves, from 196 to 274 ms each, among the longest publication pauses of the run. Restore rebuilds the interrupted row from the checkpoint alone, so the start now publishes only the sidecar, as settlement already did, and falls back to the full save only to materialize a session that has no sidecar yet.

The replacement review exposed a limit in the preflight claim: a runtime macro can construct a prohibited standalone call after an earlier authorized tool. We retain the language and its existing no-rollback semantics. Static and literal-indirect violations fail before execution; computed calls fail before the prohibited tool dispatches. Direct classification requires provably pure arguments, so an indirect nested call cannot prematurely settle the envelope.

September 2026: bound redundant direct-call text audits

A saved 9.6 MB tool span contained a direct Eval child audit with a 9.26 million character result, although the outer, displayed result was only 2,366 characters. The direct-call path had bypassed the existing preview bound. Direct text child results now use that bound; the actual return value and outer result are not truncated here. Non-text results and underlying renderer metadata stay intact. An in-memory transformation retained identical full view text and expanded Eval output. The initial serialized benchmark fixture did not retain that equivalence after reload, so its latency figures are withdrawn. Original saved sessions are not rewritten.

Large-result checkpoint placement

A 2026-09-21 native 9 MiB tool replay found the driver serializing the full result inside its recovery sidecar before the provider pipeline separately persisted that output. Moving settlement into the pipeline after projection removes that redundant large sidecar and commits the referenced output at the same marker. The checkpoint retains the same schema. Recovery displays the saved preview and resource reference; it does not reconstruct a multi-megabyte inline tool row. The driver still commits the running envelope before any child effects.