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.