Trailblaze Decision 038 PR A2: Synchronous Tool Execution from JS¶
Follow-up to Decision 038 PR A (the QuickJS bridge primitive). This devlog captures
the design decisions that land with PR A2, which ships exactly one capability: the
trailblaze.execute() callback surface that lets scripts call other Trailblaze tools.
Scope¶
PR A2 turns scripted tools from “static composition” into “observe and react” by adding a synchronous host binding that reenters Trailblaze’s tool dispatcher from JS.
This is the single load-bearing capability — every downstream scripted-tools path
(PR A3 subprocess MCP, PR A4 on-device bundles, the inline script: tool PR A
ships) depends on it. Everything else we discussed as “nice-to-have” (memory.set,
keys/entries listing, device namespace, typed memory values) is explicitly out of
scope for this PR and will land later if real use cases demand it.
In scope¶
trailblaze.execute(toolName, params): TrailblazeToolResult— synchronous host binding exposed to JS. Dispatches into the Kotlin tool registry, runs the named tool, returns a result object the script can pattern-match on.- JS-side result mirror — minimum viable:
{ type: "Success" | "Error", message?: string }. Richer variant detail (MissingRequiredArgs, FatalError, etc.) is a later enhancement via_meta.trailblaze; see the conventions devlog. - Reentrance model — JS → Kotlin → JS (a scripted tool calling another scripted tool) → Kotlin is legitimate. Single dispatcher per session; recursion depth bounded (~16) to catch typos.
- Recording discipline — consistent with Decision 025 and PR A, the
script:delegating tool remainsisRecordable = false(the wrapper is not replayed). Each primitive the script invokes viatrailblaze.execute()records as its own entry in the replayable trail, same as if the agent had dispatched it directly. Replay on a JVM that has never loaded the script reruns that recorded primitive sequence; it does not re-evaluate JS. - Thread-safety invariants —
TrailblazeToolExecutionContextmutations hold across reentrance. Explicit test.
Explicitly out of scope (moved to later PRs)¶
trailblaze.memory.set()— deferred; needs a recordable setter primitive to preserve replay semantics. Revisit when a real use case lands.trailblaze.memory.keys()/entries()listing — deferred; cheap add when needed.input.device.*namespace — deferred; symmetric with YAML{{device.x}}when that lands.- Typed memory values (lists/objects beyond strings) — deferred; bigger change touching storage and YAML interpolation semantics.
- Timeout / interrupt discipline — Decision 038’s execution-model devlog
(
2026-04-20-scripted-tools-execution-model.md) lists per-execute()wall-clock timeouts + total budget via QuickJS interrupt handler as concerns that land with PR A2. Read that as the target model:quickjs-kt1.0.5 exposes no interrupt handler, so awhile(true){}in JS never yields andwithTimeoutcannot fire. Real wall-clock budget ships in a follow-up gated on either aquickjs-ktupgrade or the Zipline migration. PR A2 itself does not enforce timeouts.
Design decisions¶
Synchronous, not suspending¶
trailblaze.execute() exposes a synchronous signature to JS. Under the hood, the
Kotlin dispatcher uses runBlocking to call the tool’s suspending execute().
This is the only shape that works without threading Promise/await through QuickJS,
and it matches Decision 025’s original design. Each trailblaze.execute() call is
its own blocking dispatch.
Threading caveat. runBlocking inside an already-suspending tool dispatch
can starve a shared coroutine pool if the evaluation thread is owned by a
dispatcher with limited parallelism. Today, TrailblazeScriptEngine uses
Dispatchers.Default (multi-threaded work-stealing), which avoids the
single-threaded-deadlock case. If we later move the engine to a
limited-parallelism or single-threaded dispatcher (e.g. for deterministic ordering
across concurrent sessions), the dispatcher must run on a dedicated executor or
use Dispatchers.IO so reentrant runBlocking can’t deadlock. Worth explicit
thought any time the engine’s threading changes.
Errors as objects, not exceptions (at the JS boundary)¶
Recoverable errors return to JS as plain objects with { type: "Error", message }.
Scripts branch on result.type without try/catch noise. Only FatalError throws
on the JS side — matching the Kotlin contract where fatal errors unwind the whole
session.
Recording: only expanded primitives record¶
The script: wrapper does NOT appear in the replayable trail recording — it’s
isRecordable = false, as PR A established. Each trailblaze.execute() call the
dispatcher routes through records as its own primitive entry (the dispatcher
invokes the same logToolExecution helper the agents use for normal dispatch).
Replay replays those primitives directly; no JS runs at replay time. This is PR
A’s discipline extended to reentrant calls, not a new recording model.
Reentrance depth cap¶
Bounded at ~16 frames. Five-deep composition is fine; an infinite-recursion typo gets caught with a clear exception rather than a stack overflow. The exact number is arbitrary — the point is fail-fast with a useful message.
Why this is the load-bearing PR for everything after¶
The execute-callback is the one piece every downstream authoring path needs:
- PR A3 (subprocess MCP toolsets) — author’s Node-side tool handlers call
trailblaze.execute()to invoke primitives. - PR A4 (on-device MCP toolsets in QuickJS) — same call, same binding, just a different evaluation context.
- Inline
script:tool (PR A’s surface) — getstrailblaze.execute()for free, immediately useful for polling loops and try/fallback patterns.
Shipping this primitive in isolation keeps PR A2 reviewable and unblocks all three paths simultaneously.
References¶
docs/devlog/2026-04-20-scripted-tools-execution-model.md— Decision 038, the four-PR roadmap this PR implements phase two of.docs/devlog/2026-04-20-scripted-tools-mcp-conventions.md— the MCP-extension conventions PRs A3/A4 use. PR A2’s JS result mirror aligns with the minimum (isError-only) shape documented there.docs/devlog/2026-04-20-scripted-tools-mcp-subprocess.md— PR A3, which consumestrailblaze.execute()from Node-side SDK handlers.docs/devlog/2026-04-20-scripted-tools-on-device-bundle.md— PR A4, which consumestrailblaze.execute()from QuickJS-evaluated bundles.