Skip to content

feat: Layer 1 deferred (on-demand) tool loading — defer heavy schemas via load_tool - #30

Merged
Astro-Han merged 11 commits into
mainfrom
claude/deferred-tools
Jun 17, 2026
Merged

feat: Layer 1 deferred (on-demand) tool loading — defer heavy schemas via load_tool#30
Astro-Han merged 11 commits into
mainfrom
claude/deferred-tools

Conversation

@Astro-Han

@Astro-HanAstro-Han commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

Summary

Layer 1 of the tool-loading roadmap (issue #15): deferred (on-demand) tool loading. The heavy-schema tool families are withheld from the per-turn prompt and loaded on demand via a small always-on load_tool catalog, cutting the per-turn tool-schema weight by ~2,143 tokens (−69.3%) whenever those families are idle — which is the common case.

This is not tool-retrieval/selection. At 17 tools we are well below the ~30–50 degradation threshold (OpenAI <20, Anthropic 30–50). It is a pure token-weight optimization: keep every tool's existence visible, defer only the expensive parameter schema.

  • Two-tier exposure, one registry. A new exposure?: 'direct' | 'deferred' field on MakaTool (default direct). providerTools stays the full registry for dispatch; the model-visible activeTools set is direct ∪ {load_tool} ∪ loaded-deferred. The AI SDK serializes only activeTools to the provider (verified against ai@6.0.185 source — prepareToolsAndToolChoice filters tools by activeTools, only the filtered array reaches doStream), so deferred-and-unloaded schemas stay off the wire across OpenAI / Anthropic / Google / openai-compatible.
  • load_tool = lookup, not search. Always-on, tiny. Its static description carries one card per namespace (rive / office / browser) = name + one-line summary, so the model knows the capability exists and expands one by name. load_tool({ namespace }) returns a thin{ loaded: [...toolNames] } — never the JSON schema (that would double-bill: once in history, once in the provider tools next step). The schema reaches the model only via activeTools.
  • Same-turn activation via prepareStep.streamText is handed activeTools once and re-filters every internal step against that frozen array, so without prepareStep a tool loaded at step N stays invisible until the next user turn. The backend now installs a prepareStep that recomputes activeTools before each step from the step history it is handed (steps[].toolCalls for load_tool) — stateless, no race with tool execution. A tool loaded at step N is advertised at step N+1 of the same turn.
  • Execute-boundary guard (step snapshot).ToolRuntime holds a per-step active snapshot (fed by the backend from prepareStep's onActiveSnapshot) and rejects a deferred tool absent from it — after the ToolCallMessage (call/result pairing stays intact) but before permission eval and the real impl. It uses the step-start snapshot, not a cumulative loaded-set: if one step emits load_tool(browser) + browser_click in parallel, browser_click is rejected (browser activates only at the next step's prepareStep). This closes the known AI SDK activeTools leak (activeTools filtering allows execution of tool calls not in active tools list vercel/ai#8653) and makes the rejection recoverable — the model loads, then retries.
  • Durable cross-turn ratchet. The loaded-set is reconstructed at turn start from the durable RuntimeEvent ledger (seedNamespacesFromRuntimeEvents scans committed load_toolfunction_call events), so a tool loaded earlier in the session is re-advertised on every later turn. It survives history compaction and session recovery because it reads the ledger, not the prompt tail. Append-only: once loaded, a tool stays active for the session (no idle eviction → fewer cache resets, no capability drift). An aborted load simply never reaches committed history; re-loading on retry is idempotent.
  • Honest cost diagnostics (Add DeepSeek cost-runtime policy for Runtime v2/AiSdkFlow #19 synergy).toolSchemaHash / toolSchemaCharsForDiagnostics now measure the active (provider-visible) subset, not the full registry — otherwise an inactive deferred schema would over-count tokens and a change to an unadvertised schema would false-fire tool_schema_changed. A genuine load still moves the hash and is labeled tool_schema_changed: one bounded prompt-cache reset the first time each family loads per session, then a leaner and stable prefix.
  • Desktop wiring.main.ts tags the heavy families (RiveWorkflow, OfficeDocument×2, the 6 browser_* tools) deferred, builds the 3-namespace catalog from the real tool names, registers buildLoadTool(catalog), and threads deferredCatalog into AiSdkBackend.

Design notes (where the deltas came from)

The mechanism is proven (Claude Code reverted to this two-tier form in v2.1.72; PawWork ships it). Before building, a Codex second-opinion pass over the design produced five corrections, all folded in:

  • Δ1prepareStep is mandatory for same-turn use (the frozen-activeTools trap above).
  • Δ2load_tool returns the thin {loaded} result, never the schema (no double-billing).
  • Δ3 diagnostics measure the active subset, not full providerTools.
  • Δ4 seed the loaded-set from a durable source (ledger), not just the prompt tail.
  • Δ5 the guard uses the per-step snapshot, not the cumulative set, to handle the same-step parallel load_tool(x)+x trap.

Deliberate scope

  • Defer only the heavy families. Rive (17+ params), Office×2, browser×6 dominate the schema weight; core 5 (Read/Write/Bash/Glob/Grep) + Skill + WebSearch + Explore + load_tool stay always-on. Explore stays direct (single + frequent). Confirmed by measurement (below) — the heavy families are 8,570 of 12,358 schema chars.
  • Provider-neutral. No Anthropic defer_loading / OpenAI tool_search natives — Maka spans DeepSeek / Ollama / openai-compatible / Gemini / Anthropic, so gating happens in activeTools (which all adapters honor) rather than a provider feature.
  • Append-only, no idle eviction. Eviction would trade a one-time-per-family cache reset for repeated resets + capability drift. The ratchet keeps the prefix stable after first load.
  • Test-gated, default-on, with a kill-switch. The mechanism is proven, so the gate is a rigorous suite + one env switch (MAKA_DISABLE_DEFERRED_TOOLS → all tools direct, no load_tool, inert guard), not a gradual rollout.
  • One cache reset per first-load is intentional and bounded. Provider-neutral means a load mutates the tools block once per family per session; Add DeepSeek cost-runtime policy for Runtime v2/AiSdkFlow #19 labels it tool_schema_changed. Net: leaner per-turn weight and a stable prefix between loads.

Verification

Re-run on this branch (rebased onto latest main):

  • npm run typecheck — clean across core / storage / runtime / ui / desktop (main.ts included).
  • Unit tests:
    • core 618 pass / 0 fail
    • storage 82 pass / 0 fail
    • desktop 1476 pass / 0 fail
    • runtime 529 pass / 1 fail of 530 — the 1 fail is the pre-existing, environment-dependent network/proxy-test ("times out when the proxy accepts TCP but never responds" asserts /timeout/i, but in this sandbox fetch returns "fetch failed" before the timeout fires). This PR touches no network code.

New tests (33) covering every slice:

  • Exposure gating (request-shape.test.ts, 4): deferred excluded from activeTools until loaded; providerTools stays full; invalid never advertised; omitted exposure = direct.
  • Wire-trim (deferred-tools-wire.test.ts, 2): a MockLanguageModelV3 through ModelAdapter.startStream proves an unloaded deferred tool never reaches doStream.
  • load_tool (load-tool.test.ts, 4): valid namespace → {loaded}; unknown rejected; no schema in the result.
  • Same-turn activation (deferred-tools-prepare-step.test.ts, 1): end-to-end — a tool loaded at step 0 reaches the provider at step 1.
  • Per-step derivation + durable seed (deferred-activation.test.ts, +): loadedNamespacesFromSteps, seedNamespacesFromRuntimeEvents, computeActiveTools, buildDeferredPrepareStep.
  • Active-subset diagnostics (request-shape.test.ts, 3): char/hash reflect the active subset; an inactive deferred schema change does NOT move the hash; a load does (tool_schema_changed).
  • Execute-boundary guard (deferred-guard.test.ts, 4): a deferred tool absent from the snapshot is rejected with no impl and no permission eval; an active one runs; inert with no snapshot installed; a direct tool is never gated.
  • Live backend (deferred-tools-backend.test.ts, 7): step 0 hides the unloaded deferred tool but advertises load_tool; a prior-turn load re-advertises it next turn (durable seed); same-step parallel load_tool(browser)+browser_click rejects the click through the real AiSdkBackend; a mis-cased BROWSER_CLICK emitted the step after load_tool(browser) repairs to canonical browser_click and runs; a same-turn load_tool(browser) records tool-schema cost for the expanded set, not the lean step-0 set; the context-budget high-water "after" hash stays equal to the final recorded requestShapeHash across a same-turn load; a deferred-tagged tool with no catalog stays advertised at step 0.
  • load_tool presentation (load-tool-presentation.test.ts, 5, desktop): localized display name (加载工具组 / "Load tools"); friendly result-card copy in zh + en with singular/plural counts; generic-title fallback when the namespace is missing; null (→ generic JSON preview) on an unexpected result shape.

Review follow-ups

  • Codex [P2] — the repair callback matched case-drifted tool names against the first-step activeTools, so a mis-cased loaded deferred name would route to invalid instead of repairing. Fixed in fd9445e1: the repair set now follows the current step's active snapshot.
  • GPT-Pro [P2] — cost/cache telemetry was computed once at stream start from the step-0 active set, so the first turn that called load_tool under-reported the heavy schema actually sent on step N+1 and surfaced the cache reset a turn late. Fixed in ff9c4827: one computeTurnDiagnostics(active) source of truth — the stream-start trace reports the step-0 view (what the first request carries), then the durable cost record + prefix baseline are refined against the final active set once the stream is consumed (no-op when nothing loaded).
  • GPT-Pro [P3]canonicalizeToolSet() hid any exposure:'deferred' tool when called without a loaded-names set, but an absent deferredCatalog is documented as "advertise everything". A deferred tag with no catalog would have stranded the tool with no load_tool to recover it. Fixed in ff9c4827: with no catalog the backend seeds all deferred names as loaded, and the canonicalizeToolSet contract is now documented.
  • GPT-Pro Web [P3] — after rebasing onto main (which added a context-budget high-water block), highWaterRequestShapeHashAfter was set from the step-0 shape and never refreshed when a same-turn load refined the recorded shape, leaving the two diagnostic fields internally inconsistent. Fixed in c1ec3404: a single publishTurnDiagnostics() updates the cost record, the prefix baseline, and the high-water "after" hash together; the "before" hash stays the pre-turn baseline.
  • UX follow-up — the always-on load_tool surfaced as its raw name + raw JSON result. Given a friendly, locale-aware presentation (display name + result card, following the zh/en UI setting) in 38552cae — pure renderer change, no backend types touched.

Follow-on fix: parallel permission requests (found in local Electron testing)

Exercising deferred loading in the running app made browser_snapshot appear to "fail" whenever deferral was ON. Reading two ON-session RuntimeEvent ledgers gave the real cause: after load_tool(browser) the model (deepseek) batch-calledbrowser_snapshot + browser_extract in parallel, producing two permission_request events for one session. The renderer held a single pending permission per session, so the second request overwrote the first; the overwritten one could never be answered — its tool stayed parked while run status flipped back to running — and the turn hung until the user force-stopped it (browser_snapshot then failed with errorClass: "Permission").

Deferral is only the trigger (loading a whole group makes the model batch-call it); activation itself is correct — snapshot passes the guard and reaches permission normally. Fixed in 49bacf23 by replacing the single slot with a per-session FIFO queue (@maka/uipermission-queue): parallel requests all survive and are cleared one at a time (queue head = active overlay). Unit-tested (permission-queue.test.ts, 6) including the exact stranded-snapshot scenario; the main.tsx complete-case source contract updated to assert clearPermissions(...).

Codex review (/codex review, xhigh) — three P2 advisories, all fixed in 043cfc32:

  • [P2] rememberForTurn didn't absorb concurrent same-scope parked requests.browser_* shares one turn-scope, so a parallel batch that all parks then gets one "remember for this turn" answer should not re-prompt for the rest. recordResponse now resolves the same-scope parked promises; each tool's coroutine emits its own ack and the UI queue drains without a second click.
  • [P2] An expired/timed-out permission could resurface as an un-answerable overlay. Timeout emits a tool_result, not a permission_decision_ack, so the FIFO queue kept the stale entry. The renderer's tool_result handler now dequeues by toolUseId (dequeuePermissionByToolUseId); no-op on the normal ack path.
  • [P2] Telemetry toolCount still counted the full registry. Now counts the active (provider-visible) subset, matching toolSchemaChars, so the cost/diagnostic record reflects the on-demand drop under deferred loading.

Token before/after (authoritative, real builders; measured set = core 5 + Explore + heavy 9):

tools advertisedtool-schema chars≈ tokens
Deferral OFF (all advertised)1512,358~3,090
Deferral ON, heavy families idle73,788~947
Saved per idle turn8,570~2,143 (−69.3%)

The load_tool catalog itself costs 762 chars. (Skill/WebSearch are excluded from the table — direct in both modes, so they don't change the absolute saving; the % is relative to the measured set.)

Astro-Han added a commit that referenced this pull request Jun 17, 2026
…ive snapshot
The repair callback passed first-step `activeTools` (captured before any
mid-turn load) as the set to match a case-drifted tool name against. Once a
deferred namespace is loaded, `prepareStep` expands the provider-visible tools
for later steps, but the repair list did not follow — so a provider that emitted
a mis-cased loaded deferred name (e.g. `BROWSER_CLICK` after loading `browser`)
no longer matched the canonical tool and was wrongly routed to `invalid`, even
though that tool was advertised in that step.
Follow the current step's active snapshot for the repair set (falling back to
the static active set when no deferred catalog is configured). Adds a live
AiSdkBackend regression: load browser at step 0, emit `BROWSER_CLICK` at step 1,
assert it repairs to canonical `browser_click` and runs instead of routing to
`invalid`.
Addresses the Codex review [P2] on PR #30.
Astro-Han added a commit that referenced this pull request Jun 17, 2026
…catalog path
Addresses two findings from a GPT-Pro review of PR #30.
P2 — same-turn deferred loads were invisible to cost/cache telemetry. send()
computed toolSchemaChars/hash and the request-shape diagnostic once at stream
start from the step-0 active set, but prepareStep expands the provider tool set
on later steps when a family is loaded this turn. So the first turn that called
load_tool under-reported the heavy schema it actually sent on step N+1, and the
cache reset surfaced a turn late. Extract a computeTurnDiagnostics(active)
closure (one source of truth), report the step-0 view in the stream-start trace
(literally what the first request carries), then refine the durable cost record
+ prefix baseline against the final active set once the stream is consumed —
a no-op on the common no-load path (the ratchet only grows the set, so an
unchanged length means nothing loaded). Next turn now classifies as stable
instead of tool_schema_changed.
P3 — the no-catalog path was not self-protecting. canonicalizeToolSet() hides
any exposure:'deferred' tool when called without a loaded-names set, but
AiSdkBackendInput documents an absent deferredCatalog as "every tool advertised".
A deferred tag with no catalog would strand the tool off the wire with no
load_tool to recover it. Make the contract self-enforcing: with no catalog,
seed all deferred tool names as loaded (advertise everything). Document the
loadedDeferredNames contract on canonicalizeToolSet so direct callers aren't
bitten.
Tests (deferred-tools-backend.test.ts): a same-turn load_tool(browser) records
tool-schema cost for the expanded set (Read+load_tool+browser_click), not the
lean step-0 set; a deferred-tagged tool with no catalog is still advertised at
step 0.
@Astro-Han
Astro-Hanforce-pushed the claude/deferred-tools branch from ff9c482 to dfa5ad7CompareJune 17, 2026 03:28
Astro-Han added a commit that referenced this pull request Jun 17, 2026
…ive snapshot
The repair callback passed first-step `activeTools` (captured before any
mid-turn load) as the set to match a case-drifted tool name against. Once a
deferred namespace is loaded, `prepareStep` expands the provider-visible tools
for later steps, but the repair list did not follow — so a provider that emitted
a mis-cased loaded deferred name (e.g. `BROWSER_CLICK` after loading `browser`)
no longer matched the canonical tool and was wrongly routed to `invalid`, even
though that tool was advertised in that step.
Follow the current step's active snapshot for the repair set (falling back to
the static active set when no deferred catalog is configured). Adds a live
AiSdkBackend regression: load browser at step 0, emit `BROWSER_CLICK` at step 1,
assert it repairs to canonical `browser_click` and runs instead of routing to
`invalid`.
Addresses the Codex review [P2] on PR #30.
Astro-Han added a commit that referenced this pull request Jun 17, 2026
…catalog path
Addresses two findings from a GPT-Pro review of PR #30.
P2 — same-turn deferred loads were invisible to cost/cache telemetry. send()
computed toolSchemaChars/hash and the request-shape diagnostic once at stream
start from the step-0 active set, but prepareStep expands the provider tool set
on later steps when a family is loaded this turn. So the first turn that called
load_tool under-reported the heavy schema it actually sent on step N+1, and the
cache reset surfaced a turn late. Extract a computeTurnDiagnostics(active)
closure (one source of truth), report the step-0 view in the stream-start trace
(literally what the first request carries), then refine the durable cost record
+ prefix baseline against the final active set once the stream is consumed —
a no-op on the common no-load path (the ratchet only grows the set, so an
unchanged length means nothing loaded). Next turn now classifies as stable
instead of tool_schema_changed.
P3 — the no-catalog path was not self-protecting. canonicalizeToolSet() hides
any exposure:'deferred' tool when called without a loaded-names set, but
AiSdkBackendInput documents an absent deferredCatalog as "every tool advertised".
A deferred tag with no catalog would strand the tool off the wire with no
load_tool to recover it. Make the contract self-enforcing: with no catalog,
seed all deferred tool names as loaded (advertise everything). Document the
loadedDeferredNames contract on canonicalizeToolSet so direct callers aren't
bitten.
Tests (deferred-tools-backend.test.ts): a same-turn load_tool(browser) records
tool-schema cost for the expanded set (Read+load_tool+browser_click), not the
lean step-0 set; a deferred-tagged tool with no catalog is still advertised at
step 0.
Astro-Han added a commit that referenced this pull request Jun 17, 2026
A model firing several tool calls in one step (e.g. browser_snapshot +
browser_extract in parallel) produces multiple permission_request events for one
session. The renderer kept a single pending request per session, so the later
request overwrote the earlier one — the overwritten request could never be
answered, its tool stayed parked forever while run status flipped back to
"running", and the turn hung until the user force-stopped it. Confirmed in two
ON-session ledgers: snapshot's request f2d2b2b7 never got a decision, the user
hit stop, snapshot failed with errorClass "Permission".
Replace the single slot with a per-session FIFO queue: parallel requests all
survive, the user clears them one at a time (queue head = active overlay),
nothing is stranded. Pure helpers (enqueue dedups by requestId, dequeue promotes
the next, clear drops all, activePermissionFor reads the head) live in @maka/ui,
unit-tested including the exact stranded-snapshot scenario.
Surfaced while testing deferred tool loading (PR #30): deferred is only the
trigger — loading a whole group makes the model batch-call it in parallel, which
exposes this pre-existing single-slot bug. Deferred activation itself is correct.
- packages/ui/src/permission-queue.ts: FIFO helpers + barrel export
- apps/desktop/src/renderer/main.tsx: permissionBySession -> queue
- tests: permission-queue.test.ts (6); update complete-case source contract to
assert clearPermissions(...)
Astro-Han added a commit that referenced this pull request Jun 17, 2026
A Codex review of the PR surfaced three advisory (no P1) issues on top of the
parallel-permission-queue fix. Fixed together rather than carried as debt.
1. permission-engine: allow + rememberForTurn now absorbs other already-parked
requests sharing the same scope. browser_* shares one turn-scope, so when a
parallel batch (snapshot + extract + ...) all park and the user answers one
with "remember for this turn", the rest must not each re-prompt.
recordResponse resolves the same-scope parked promises; each tool's own
coroutine emits its own permission_decision_ack, so the UI queue drains
without a second click.
2. renderer: drain a stale permission-queue entry on tool_result. A permission
that ends without a user decision (runtime timeout / expiry) emits a
tool_result, not a permission_decision_ack -- so the FIFO queue could surface
an already-expired request as an un-answerable overlay. The tool_result
handler now dequeues by toolUseId (new dequeuePermissionByToolUseId helper);
no-op on the normal allow/deny path where the ack already dequeued.
3. ai-sdk-backend: telemetry toolCount now counts the active (provider-visible)
tool subset instead of the full registry, matching toolSchemaChars. Under
deferred loading the cost/diagnostic record now reflects the on-demand drop.
Tests: permission-engine (absorb same-scope; don't absorb without remember or
across scopes), permission-queue (dequeueByToolUseId drains + no-ops). Full
typecheck clean; runtime affected suites 72/72; desktop permission + contract
tests green.
…l, same-turn prepareStep
Layer 1 mechanism (issue #15). Heavy-schema tools (Rive/Office/browser) can be withheld
from the per-turn prompt and loaded on demand, keeping the cached provider prefix lean.
- MakaTool.exposure 'direct'|'deferred'; canonicalizeToolSet trims deferred-and-unloaded
tools from activeTools while keeping providerTools full for dispatch.
- load_tool: always-on namespace catalog (cards = name + one-line summary), thin
{ loaded:[...] } result (no schema), provider-neutral lookup (not search).
- deferred-activation + ModelAdapter prepareStep forward: per-step active set derived from
prior steps' load_tool calls, so a tool loaded at step N is advertised at step N+1.
Proven: MockLanguageModelV3 wire-trim (a deferred schema never crosses the wire) and an
end-to-end same-turn load->use test (Codex Δ1). 20 new tests; runtime suite 499/500
(the 1 failure is a pre-existing env-dependent network-proxy test).
Pending: live AiSdkBackend wiring (execute-guard, active-subset diagnostics, durable
cross-turn seed) and main.ts registration + token measurement.
… subset
Codex Δ3: toolSchemaHash and toolSchemaCharsForDiagnostics now hash/measure
only the active (provider-visible) subset, not the full providerTools registry.
Without this, an inactive deferred tool's schema would over-count tokens in #19
and a change to an unadvertised schema would false-fire tool_schema_changed.
A genuine load still moves the hash and reports tool_schema_changed.
3 new tests in request-shape.test.ts (7/7 green).
…durable seed)
Slices 5/7/8 (runtime side):
- S5 execute-boundary guard: ToolRuntime gains a per-step active snapshot
(setStepActivation) and rejects a deferred tool absent from it — before
permission eval and the real impl. Uses the step-start snapshot, not a
cumulative set, so same-step parallel load_tool(x)+x rejects x (Codex Δ5).
Closes the AI SDK activeTools leak (vercel/ai#8653). Recoverable message.
- S7 durable cross-turn seed: seedNamespacesFromRuntimeEvents reconstructs the
loaded-set from committed load_tool function_call events in the ledger, so a
tool loaded earlier this session is re-advertised at turn start. Survives
compaction/recovery; aborted loads never reach committed history (Codex Δ4).
- S8 backend wiring: AiSdkBackendInput.deferredCatalog drives a per-turn
prepareStep (seed ∪ this-turn loads), feeds the guard snapshot via
onActiveSnapshot, and seeds step-0 activeTools + diagnostics consistently.
Inert when no catalog is configured (legacy behavior unchanged).
Tests: 4 guard (ToolRuntime), 3 durable-seed, 3 live-backend integration
(hide-unloaded, cross-turn re-advertise, same-step parallel reject). Suite
512/513 (the 1 fail is the pre-existing network-proxy env test).
…wser via load_tool
Tags the heavy-schema families (RiveWorkflow, OfficeDocument×2, browser×6) as
exposure:'deferred', builds the 3-namespace catalog (rive/office/browser) from
the real tool names, registers buildLoadTool(catalog), and threads
deferredCatalog into AiSdkBackend so their schemas stay off the per-turn wire
until the model loads them on demand.
Kill-switch: set MAKA_DISABLE_DEFERRED_TOOLS to any value to advertise every
tool every turn (no exposure tag, no load_tool, inert guard).
Authoritative measurement (core+explore+heavy): ~2,143 tokens saved per idle
turn (12,358→3,788 schema chars, −69.3%); load_tool catalog costs 762 chars.
Desktop main suite 1469/1469.
…ive snapshot
The repair callback passed first-step `activeTools` (captured before any
mid-turn load) as the set to match a case-drifted tool name against. Once a
deferred namespace is loaded, `prepareStep` expands the provider-visible tools
for later steps, but the repair list did not follow — so a provider that emitted
a mis-cased loaded deferred name (e.g. `BROWSER_CLICK` after loading `browser`)
no longer matched the canonical tool and was wrongly routed to `invalid`, even
though that tool was advertised in that step.
Follow the current step's active snapshot for the repair set (falling back to
the static active set when no deferred catalog is configured). Adds a live
AiSdkBackend regression: load browser at step 0, emit `BROWSER_CLICK` at step 1,
assert it repairs to canonical `browser_click` and runs instead of routing to
`invalid`.
Addresses the Codex review [P2] on PR #30.
…catalog path
Addresses two findings from a GPT-Pro review of PR #30.
P2 — same-turn deferred loads were invisible to cost/cache telemetry. send()
computed toolSchemaChars/hash and the request-shape diagnostic once at stream
start from the step-0 active set, but prepareStep expands the provider tool set
on later steps when a family is loaded this turn. So the first turn that called
load_tool under-reported the heavy schema it actually sent on step N+1, and the
cache reset surfaced a turn late. Extract a computeTurnDiagnostics(active)
closure (one source of truth), report the step-0 view in the stream-start trace
(literally what the first request carries), then refine the durable cost record
+ prefix baseline against the final active set once the stream is consumed —
a no-op on the common no-load path (the ratchet only grows the set, so an
unchanged length means nothing loaded). Next turn now classifies as stable
instead of tool_schema_changed.
P3 — the no-catalog path was not self-protecting. canonicalizeToolSet() hides
any exposure:'deferred' tool when called without a loaded-names set, but
AiSdkBackendInput documents an absent deferredCatalog as "every tool advertised".
A deferred tag with no catalog would strand the tool off the wire with no
load_tool to recover it. Make the contract self-enforcing: with no catalog,
seed all deferred tool names as loaded (advertise everything). Document the
loadedDeferredNames contract on canonicalizeToolSet so direct callers aren't
bitten.
Tests (deferred-tools-backend.test.ts): a same-turn load_tool(browser) records
tool-schema cost for the expanded set (Read+load_tool+browser_click), not the
lean step-0 set; a deferred-tagged tool with no catalog is still advertised at
step 0.
The always-on deferred-loading catalog tool surfaced as its raw name
`load_tool` with a raw `{ loaded: [...] }` JSON result card. Give it a
friendly, locale-aware presentation that follows the existing detectUiLocale
zh/en preference:
- display name: 加载工具组 / "Load tools"
- result card: 已加载 <ns> 工具组 / 新增 N 个可用工具:… / 下一步即可调用
("Loaded <ns> tool group" / "N tools now available: …" / "Ready to use on
the next step"), falling back to the generic JSON preview on an unexpected
shape (e.g. a load failure, which is a text/error result).
Pure renderer change — no backend/runtime types touched. The card reads the
namespace from the call args and the tool list from the result. Copy logic is
extracted as pure functions (describeLoadToolResult / loadToolDisplayName) and
unit-tested in the desktop suite.
…urn load
The rebase grafted main's context-budget high-water block onto step-0
diagnostics: it set highWaterRequestShapeHashAfter from the step-0 request shape
before streaming. But a same-turn deferred load later refines the request shape /
prompt segments to the final active set without touching that nested high-water
hash — leaving "after" describing step-0 while the recorded requestShapeHash
describes the final set. Internally inconsistent diagnostic metadata.
Centralize publishing: a single publishTurnDiagnostics() updates the cost record,
the prefix baseline, AND the high-water "after" hash together, so they can never
diverge. The "before" hash is the pre-turn baseline, set once at step 0; the
post-stream refinement re-publishes the final snapshot.
Test: wrap buildPriorMessages to inject a high-water marker (real high-water
reasons need the synthesis-cache subsystem's valid blocks + matching history,
which would brittly couple the test to it) + a same-turn load_tool, then assert
highWaterRequestShapeHashAfter equals the final recorded requestShapeHash.
Addresses the GPT-Pro Web review [P3].
A model firing several tool calls in one step (e.g. browser_snapshot +
browser_extract in parallel) produces multiple permission_request events for one
session. The renderer kept a single pending request per session, so the later
request overwrote the earlier one — the overwritten request could never be
answered, its tool stayed parked forever while run status flipped back to
"running", and the turn hung until the user force-stopped it. Confirmed in two
ON-session ledgers: snapshot's request f2d2b2b7 never got a decision, the user
hit stop, snapshot failed with errorClass "Permission".
Replace the single slot with a per-session FIFO queue: parallel requests all
survive, the user clears them one at a time (queue head = active overlay),
nothing is stranded. Pure helpers (enqueue dedups by requestId, dequeue promotes
the next, clear drops all, activePermissionFor reads the head) live in @maka/ui,
unit-tested including the exact stranded-snapshot scenario.
Surfaced while testing deferred tool loading (PR #30): deferred is only the
trigger — loading a whole group makes the model batch-call it in parallel, which
exposes this pre-existing single-slot bug. Deferred activation itself is correct.
- packages/ui/src/permission-queue.ts: FIFO helpers + barrel export
- apps/desktop/src/renderer/main.tsx: permissionBySession -> queue
- tests: permission-queue.test.ts (6); update complete-case source contract to
assert clearPermissions(...)
A Codex review of the PR surfaced three advisory (no P1) issues on top of the
parallel-permission-queue fix. Fixed together rather than carried as debt.
1. permission-engine: allow + rememberForTurn now absorbs other already-parked
requests sharing the same scope. browser_* shares one turn-scope, so when a
parallel batch (snapshot + extract + ...) all park and the user answers one
with "remember for this turn", the rest must not each re-prompt.
recordResponse resolves the same-scope parked promises; each tool's own
coroutine emits its own permission_decision_ack, so the UI queue drains
without a second click.
2. renderer: drain a stale permission-queue entry on tool_result. A permission
that ends without a user decision (runtime timeout / expiry) emits a
tool_result, not a permission_decision_ack -- so the FIFO queue could surface
an already-expired request as an un-answerable overlay. The tool_result
handler now dequeues by toolUseId (new dequeuePermissionByToolUseId helper);
no-op on the normal allow/deny path where the ack already dequeued.
3. ai-sdk-backend: telemetry toolCount now counts the active (provider-visible)
tool subset instead of the full registry, matching toolSchemaChars. Under
deferred loading the cost/diagnostic record now reflects the on-demand drop.
Tests: permission-engine (absorb same-scope; don't absorb without remember or
across scopes), permission-queue (dequeueByToolUseId drains + no-ops). Full
typecheck clean; runtime affected suites 72/72; desktop permission + contract
tests green.
…mantics
PR #30's P2 #3 redefines promptSegment.toolCount as the model-visible
(active) tool count, not the full providerTools registry (which includes
the invalid fallback plus any deferred-unloaded / economy-hidden schemas
that never reach the wire). After rebasing onto Tool Source Economy
(PR #34), three economy tests still asserted the old providerTools.length
values; update them to the active counts, which now equal
modelToolNames().length in each case.
@Astro-Han
Astro-Hanforce-pushed the claude/deferred-tools branch from 043cfc3 to 91f7e79CompareJune 17, 2026 07:00
@Astro-Han
Astro-Han merged commit 4e26f77 into mainJun 17, 2026
@Astro-Han
Astro-Han deleted the claude/deferred-tools branch June 17, 2026 07:21
Astro-Han added a commit that referenced this pull request Jun 17, 2026
…o ToolAvailabilityRuntime
Collapse the two parallel tool-visibility mechanisms — PR #30 deferred tool
loading and PR #34 tool source economy — into one mechanism and one policy
(issue #37):
- One `economy` switch + configurable `coreToolNames`; grouping comes from
`ToolAvailabilityConfig.groups`, not the per-tool `exposure` tag (removed
from MakaTool). The now-dead `toolSource` tag is swept separately next.
- One `load_tools` connector, built by the runtime. The historical `load_tool`
(PR #30) and `connect_tool_source` (PR #34) calls are accepted only as
durable ledger-seeding aliases, never advertised as provider-visible tools.
- One same-turn activation policy (prepareStep + execute-boundary gating);
the next-request economy semantics are dropped.
- Delete tool-source-economy.ts, deferred-activation.ts, load-tool.ts and
their tests; add tool-availability.ts + tool-availability.test.ts.
- Rename ToolSourceEconomyDiagnostic → ToolAvailabilityDiagnostic (record
field toolSourceEconomy → toolAvailability) while keeping the historical
*SourceIds shell vocabulary (a "source" id is a catalog group id).
- Wire main.ts through a single ToolAvailabilityConfig
(economy = !MAKA_DISABLE_DEFERRED_TOOLS; groups = rive/office/browser); the
runtime now produces the connector, so builtinTools no longer include it.
Astro-Han added a commit that referenced this pull request Jun 17, 2026
…o ToolAvailabilityRuntime
Collapse the two parallel tool-visibility mechanisms — PR #30 deferred tool
loading and PR #34 tool source economy — into one mechanism and one policy
(issue #37):
- One `economy` switch + configurable `coreToolNames`; grouping comes from
`ToolAvailabilityConfig.groups`, not the per-tool `exposure` tag (removed
from MakaTool). The now-dead `toolSource` tag is swept separately next.
- One `load_tools` connector, built by the runtime. Same-turn activation honors
only `load_tools` (and only its `group` arg). The historical `load_tool`
(PR #30) and `connect_tool_source` (PR #34) names are accepted solely when
re-seeding prior-turn activations from the durable ledger — never advertised,
never live in the current turn.
- One same-turn activation policy (prepareStep + execute-boundary gating);
the next-request economy semantics are dropped.
- Delete tool-source-economy.ts, deferred-activation.ts, load-tool.ts and
their tests; add tool-availability.ts + tool-availability.test.ts.
- Rename ToolSourceEconomyDiagnostic → ToolAvailabilityDiagnostic (record
field toolSourceEconomy → toolAvailability) while keeping the historical
*SourceIds shell vocabulary (a "source" id is a catalog group id).
- Wire main.ts through a single ToolAvailabilityConfig
(economy = !MAKA_DISABLE_DEFERRED_TOOLS; groups = rive/office/browser); the
runtime now produces the connector, so builtinTools no longer include it.
Astro-Han added a commit that referenced this pull request Jun 17, 2026
Recognize the unified `load_tools` connector (and the historical `load_tool`,
PR #30 — it shipped and returns the same `{ loaded: [...] }` shape) for the
localized "Load tools" card, reading the loaded group id from `group` with a
`namespace` fallback so replayed pre-unification sessions still render.
`connect_tool_source` (PR #34) is deliberately not presented: it never shipped,
so no such result exists, and its `{ tools: [...] }` shape differs from this
card's `{ loaded: [...] }`. The runtime still seeds from it for the durable
ledger (a separate concern that reads call args, not results).
Astro-Han added a commit that referenced this pull request Jun 17, 2026
…o ToolAvailabilityRuntime
Collapse the two parallel tool-visibility mechanisms — PR #30 deferred tool
loading and PR #34 tool source economy — into one mechanism and one policy
(issue #37):
- One `economy` switch + configurable `coreToolNames`; grouping comes from
`ToolAvailabilityConfig.groups`, not the per-tool `exposure` tag (removed
from MakaTool). The now-dead `toolSource` tag is swept separately next.
- One `load_tools` connector, built by the runtime. Same-turn activation honors
only `load_tools` (and only its `group` arg). The historical `load_tool`
(PR #30) and `connect_tool_source` (PR #34) names are accepted solely when
re-seeding prior-turn activations from the durable ledger — never advertised,
never live in the current turn.
- One same-turn activation policy (prepareStep + execute-boundary gating);
the next-request economy semantics are dropped.
- Delete tool-source-economy.ts, deferred-activation.ts, load-tool.ts and
their tests; add tool-availability.ts + tool-availability.test.ts.
- Rename ToolSourceEconomyDiagnostic → ToolAvailabilityDiagnostic (record
field toolSourceEconomy → toolAvailability) while keeping the historical
*SourceIds shell vocabulary (a "source" id is a catalog group id).
- Wire main.ts through a single ToolAvailabilityConfig
(economy = !MAKA_DISABLE_DEFERRED_TOOLS; groups = rive/office/browser); the
runtime now produces the connector, so builtinTools no longer include it.
Astro-Han added a commit that referenced this pull request Jun 17, 2026
Recognize the unified `load_tools` connector (and the historical `load_tool`,
PR #30 — it shipped and returns the same `{ loaded: [...] }` shape) for the
localized "Load tools" card, reading the loaded group id from `group` with a
`namespace` fallback so replayed pre-unification sessions still render.
`connect_tool_source` (PR #34) is deliberately not presented: it never shipped,
so no such result exists, and its `{ tools: [...] }` shape differs from this
card's `{ loaded: [...] }`. The runtime still seeds from it for the durable
ledger (a separate concern that reads call args, not results).
Astro-Han added a commit that referenced this pull request Jun 17, 2026
…o ToolAvailabilityRuntime
Collapse the two parallel tool-visibility mechanisms — PR #30 deferred tool
loading and PR #34 tool source economy — into one mechanism and one policy
(issue #37):
- One `economy` switch + configurable `coreToolNames`; grouping comes from
`ToolAvailabilityConfig.groups`, not the per-tool `exposure` tag (removed
from MakaTool). The now-dead `toolSource` tag is swept separately next.
- One `load_tools` connector, built by the runtime. Same-turn activation honors
only `load_tools` (and only its `group` arg). The historical `load_tool`
(PR #30) and `connect_tool_source` (PR #34) names are accepted solely when
re-seeding prior-turn activations from the durable ledger — never advertised,
never live in the current turn.
- One same-turn activation policy (prepareStep + execute-boundary gating);
the next-request economy semantics are dropped.
- Delete tool-source-economy.ts, deferred-activation.ts, load-tool.ts and
their tests; add tool-availability.ts + tool-availability.test.ts.
- Rename ToolSourceEconomyDiagnostic → ToolAvailabilityDiagnostic (record
field toolSourceEconomy → toolAvailability) while keeping the historical
*SourceIds shell vocabulary (a "source" id is a catalog group id).
- Wire main.ts through a single ToolAvailabilityConfig
(economy = !MAKA_DISABLE_DEFERRED_TOOLS; groups = rive/office/browser); the
runtime now produces the connector, so builtinTools no longer include it.
Astro-Han added a commit that referenced this pull request Jun 17, 2026
Recognize the unified `load_tools` connector (and the historical `load_tool`,
PR #30 — it shipped and returns the same `{ loaded: [...] }` shape) for the
localized "Load tools" card, reading the loaded group id from `group` with a
`namespace` fallback so replayed pre-unification sessions still render.
`connect_tool_source` (PR #34) is deliberately not presented: it never shipped,
so no such result exists, and its `{ tools: [...] }` shape differs from this
card's `{ loaded: [...] }`. The runtime still seeds from it for the durable
ledger (a separate concern that reads call args, not results).
jackwener pushed a commit that referenced this pull request Jun 21, 2026
…ive snapshot
The repair callback passed first-step `activeTools` (captured before any
mid-turn load) as the set to match a case-drifted tool name against. Once a
deferred namespace is loaded, `prepareStep` expands the provider-visible tools
for later steps, but the repair list did not follow — so a provider that emitted
a mis-cased loaded deferred name (e.g. `BROWSER_CLICK` after loading `browser`)
no longer matched the canonical tool and was wrongly routed to `invalid`, even
though that tool was advertised in that step.
Follow the current step's active snapshot for the repair set (falling back to
the static active set when no deferred catalog is configured). Adds a live
AiSdkBackend regression: load browser at step 0, emit `BROWSER_CLICK` at step 1,
assert it repairs to canonical `browser_click` and runs instead of routing to
`invalid`.
Addresses the Codex review [P2] on PR #30.
jackwener pushed a commit that referenced this pull request Jun 21, 2026
…catalog path
Addresses two findings from a GPT-Pro review of PR #30.
P2 — same-turn deferred loads were invisible to cost/cache telemetry. send()
computed toolSchemaChars/hash and the request-shape diagnostic once at stream
start from the step-0 active set, but prepareStep expands the provider tool set
on later steps when a family is loaded this turn. So the first turn that called
load_tool under-reported the heavy schema it actually sent on step N+1, and the
cache reset surfaced a turn late. Extract a computeTurnDiagnostics(active)
closure (one source of truth), report the step-0 view in the stream-start trace
(literally what the first request carries), then refine the durable cost record
+ prefix baseline against the final active set once the stream is consumed —
a no-op on the common no-load path (the ratchet only grows the set, so an
unchanged length means nothing loaded). Next turn now classifies as stable
instead of tool_schema_changed.
P3 — the no-catalog path was not self-protecting. canonicalizeToolSet() hides
any exposure:'deferred' tool when called without a loaded-names set, but
AiSdkBackendInput documents an absent deferredCatalog as "every tool advertised".
A deferred tag with no catalog would strand the tool off the wire with no
load_tool to recover it. Make the contract self-enforcing: with no catalog,
seed all deferred tool names as loaded (advertise everything). Document the
loadedDeferredNames contract on canonicalizeToolSet so direct callers aren't
bitten.
Tests (deferred-tools-backend.test.ts): a same-turn load_tool(browser) records
tool-schema cost for the expanded set (Read+load_tool+browser_click), not the
lean step-0 set; a deferred-tagged tool with no catalog is still advertised at
step 0.
jackwener pushed a commit that referenced this pull request Jun 21, 2026
A model firing several tool calls in one step (e.g. browser_snapshot +
browser_extract in parallel) produces multiple permission_request events for one
session. The renderer kept a single pending request per session, so the later
request overwrote the earlier one — the overwritten request could never be
answered, its tool stayed parked forever while run status flipped back to
"running", and the turn hung until the user force-stopped it. Confirmed in two
ON-session ledgers: snapshot's request f2d2b2b7 never got a decision, the user
hit stop, snapshot failed with errorClass "Permission".
Replace the single slot with a per-session FIFO queue: parallel requests all
survive, the user clears them one at a time (queue head = active overlay),
nothing is stranded. Pure helpers (enqueue dedups by requestId, dequeue promotes
the next, clear drops all, activePermissionFor reads the head) live in @maka/ui,
unit-tested including the exact stranded-snapshot scenario.
Surfaced while testing deferred tool loading (PR #30): deferred is only the
trigger — loading a whole group makes the model batch-call it in parallel, which
exposes this pre-existing single-slot bug. Deferred activation itself is correct.
- packages/ui/src/permission-queue.ts: FIFO helpers + barrel export
- apps/desktop/src/renderer/main.tsx: permissionBySession -> queue
- tests: permission-queue.test.ts (6); update complete-case source contract to
assert clearPermissions(...)
jackwener pushed a commit that referenced this pull request Jun 21, 2026
A Codex review of the PR surfaced three advisory (no P1) issues on top of the
parallel-permission-queue fix. Fixed together rather than carried as debt.
1. permission-engine: allow + rememberForTurn now absorbs other already-parked
requests sharing the same scope. browser_* shares one turn-scope, so when a
parallel batch (snapshot + extract + ...) all park and the user answers one
with "remember for this turn", the rest must not each re-prompt.
recordResponse resolves the same-scope parked promises; each tool's own
coroutine emits its own permission_decision_ack, so the UI queue drains
without a second click.
2. renderer: drain a stale permission-queue entry on tool_result. A permission
that ends without a user decision (runtime timeout / expiry) emits a
tool_result, not a permission_decision_ack -- so the FIFO queue could surface
an already-expired request as an un-answerable overlay. The tool_result
handler now dequeues by toolUseId (new dequeuePermissionByToolUseId helper);
no-op on the normal allow/deny path where the ack already dequeued.
3. ai-sdk-backend: telemetry toolCount now counts the active (provider-visible)
tool subset instead of the full registry, matching toolSchemaChars. Under
deferred loading the cost/diagnostic record now reflects the on-demand drop.
Tests: permission-engine (absorb same-scope; don't absorb without remember or
across scopes), permission-queue (dequeueByToolUseId drains + no-ops). Full
typecheck clean; runtime affected suites 72/72; desktop permission + contract
tests green.
jackwener pushed a commit that referenced this pull request Jun 21, 2026
…mantics
PR #30's P2 #3 redefines promptSegment.toolCount as the model-visible
(active) tool count, not the full providerTools registry (which includes
the invalid fallback plus any deferred-unloaded / economy-hidden schemas
that never reach the wire). After rebasing onto Tool Source Economy
(PR #34), three economy tests still asserted the old providerTools.length
values; update them to the active counts, which now equal
modelToolNames().length in each case.
jackwener pushed a commit that referenced this pull request Jun 21, 2026
feat: Layer 1 deferred (on-demand) tool loading — defer heavy schemas via load_tool
jackwener pushed a commit that referenced this pull request Jun 21, 2026
…o ToolAvailabilityRuntime
Collapse the two parallel tool-visibility mechanisms — PR #30 deferred tool
loading and PR #34 tool source economy — into one mechanism and one policy
(issue #37):
- One `economy` switch + configurable `coreToolNames`; grouping comes from
`ToolAvailabilityConfig.groups`, not the per-tool `exposure` tag (removed
from MakaTool). The now-dead `toolSource` tag is swept separately next.
- One `load_tools` connector, built by the runtime. Same-turn activation honors
only `load_tools` (and only its `group` arg). The historical `load_tool`
(PR #30) and `connect_tool_source` (PR #34) names are accepted solely when
re-seeding prior-turn activations from the durable ledger — never advertised,
never live in the current turn.
- One same-turn activation policy (prepareStep + execute-boundary gating);
the next-request economy semantics are dropped.
- Delete tool-source-economy.ts, deferred-activation.ts, load-tool.ts and
their tests; add tool-availability.ts + tool-availability.test.ts.
- Rename ToolSourceEconomyDiagnostic → ToolAvailabilityDiagnostic (record
field toolSourceEconomy → toolAvailability) while keeping the historical
*SourceIds shell vocabulary (a "source" id is a catalog group id).
- Wire main.ts through a single ToolAvailabilityConfig
(economy = !MAKA_DISABLE_DEFERRED_TOOLS; groups = rive/office/browser); the
runtime now produces the connector, so builtinTools no longer include it.
jackwener pushed a commit that referenced this pull request Jun 21, 2026
Recognize the unified `load_tools` connector (and the historical `load_tool`,
PR #30 — it shipped and returns the same `{ loaded: [...] }` shape) for the
localized "Load tools" card, reading the loaded group id from `group` with a
`namespace` fallback so replayed pre-unification sessions still render.
`connect_tool_source` (PR #34) is deliberately not presented: it never shipped,
so no such result exists, and its `{ tools: [...] }` shape differs from this
card's `{ loaded: [...] }`. The runtime still seeds from it for the durable
ledger (a separate concern that reads call args, not results).
jackwener pushed a commit that referenced this pull request Jun 21, 2026
feat: Layer 1 deferred (on-demand) tool loading — defer heavy schemas via load_tool
jackwener pushed a commit that referenced this pull request Jun 21, 2026
…ive snapshot
The repair callback passed first-step `activeTools` (captured before any
mid-turn load) as the set to match a case-drifted tool name against. Once a
deferred namespace is loaded, `prepareStep` expands the provider-visible tools
for later steps, but the repair list did not follow — so a provider that emitted
a mis-cased loaded deferred name (e.g. `BROWSER_CLICK` after loading `browser`)
no longer matched the canonical tool and was wrongly routed to `invalid`, even
though that tool was advertised in that step.
Follow the current step's active snapshot for the repair set (falling back to
the static active set when no deferred catalog is configured). Adds a live
AiSdkBackend regression: load browser at step 0, emit `BROWSER_CLICK` at step 1,
assert it repairs to canonical `browser_click` and runs instead of routing to
`invalid`.
Addresses the Codex review [P2] on PR #30.
jackwener pushed a commit that referenced this pull request Jun 21, 2026
…catalog path
Addresses two findings from a GPT-Pro review of PR #30.
P2 — same-turn deferred loads were invisible to cost/cache telemetry. send()
computed toolSchemaChars/hash and the request-shape diagnostic once at stream
start from the step-0 active set, but prepareStep expands the provider tool set
on later steps when a family is loaded this turn. So the first turn that called
load_tool under-reported the heavy schema it actually sent on step N+1, and the
cache reset surfaced a turn late. Extract a computeTurnDiagnostics(active)
closure (one source of truth), report the step-0 view in the stream-start trace
(literally what the first request carries), then refine the durable cost record
+ prefix baseline against the final active set once the stream is consumed —
a no-op on the common no-load path (the ratchet only grows the set, so an
unchanged length means nothing loaded). Next turn now classifies as stable
instead of tool_schema_changed.
P3 — the no-catalog path was not self-protecting. canonicalizeToolSet() hides
any exposure:'deferred' tool when called without a loaded-names set, but
AiSdkBackendInput documents an absent deferredCatalog as "every tool advertised".
A deferred tag with no catalog would strand the tool off the wire with no
load_tool to recover it. Make the contract self-enforcing: with no catalog,
seed all deferred tool names as loaded (advertise everything). Document the
loadedDeferredNames contract on canonicalizeToolSet so direct callers aren't
bitten.
Tests (deferred-tools-backend.test.ts): a same-turn load_tool(browser) records
tool-schema cost for the expanded set (Read+load_tool+browser_click), not the
lean step-0 set; a deferred-tagged tool with no catalog is still advertised at
step 0.
jackwener pushed a commit that referenced this pull request Jun 21, 2026
A model firing several tool calls in one step (e.g. browser_snapshot +
browser_extract in parallel) produces multiple permission_request events for one
session. The renderer kept a single pending request per session, so the later
request overwrote the earlier one — the overwritten request could never be
answered, its tool stayed parked forever while run status flipped back to
"running", and the turn hung until the user force-stopped it. Confirmed in two
ON-session ledgers: snapshot's request f2d2b2b7 never got a decision, the user
hit stop, snapshot failed with errorClass "Permission".
Replace the single slot with a per-session FIFO queue: parallel requests all
survive, the user clears them one at a time (queue head = active overlay),
nothing is stranded. Pure helpers (enqueue dedups by requestId, dequeue promotes
the next, clear drops all, activePermissionFor reads the head) live in @maka/ui,
unit-tested including the exact stranded-snapshot scenario.
Surfaced while testing deferred tool loading (PR #30): deferred is only the
trigger — loading a whole group makes the model batch-call it in parallel, which
exposes this pre-existing single-slot bug. Deferred activation itself is correct.
- packages/ui/src/permission-queue.ts: FIFO helpers + barrel export
- apps/desktop/src/renderer/main.tsx: permissionBySession -> queue
- tests: permission-queue.test.ts (6); update complete-case source contract to
assert clearPermissions(...)
jackwener pushed a commit that referenced this pull request Jun 21, 2026
A Codex review of the PR surfaced three advisory (no P1) issues on top of the
parallel-permission-queue fix. Fixed together rather than carried as debt.
1. permission-engine: allow + rememberForTurn now absorbs other already-parked
requests sharing the same scope. browser_* shares one turn-scope, so when a
parallel batch (snapshot + extract + ...) all park and the user answers one
with "remember for this turn", the rest must not each re-prompt.
recordResponse resolves the same-scope parked promises; each tool's own
coroutine emits its own permission_decision_ack, so the UI queue drains
without a second click.
2. renderer: drain a stale permission-queue entry on tool_result. A permission
that ends without a user decision (runtime timeout / expiry) emits a
tool_result, not a permission_decision_ack -- so the FIFO queue could surface
an already-expired request as an un-answerable overlay. The tool_result
handler now dequeues by toolUseId (new dequeuePermissionByToolUseId helper);
no-op on the normal allow/deny path where the ack already dequeued.
3. ai-sdk-backend: telemetry toolCount now counts the active (provider-visible)
tool subset instead of the full registry, matching toolSchemaChars. Under
deferred loading the cost/diagnostic record now reflects the on-demand drop.
Tests: permission-engine (absorb same-scope; don't absorb without remember or
across scopes), permission-queue (dequeueByToolUseId drains + no-ops). Full
typecheck clean; runtime affected suites 72/72; desktop permission + contract
tests green.
jackwener pushed a commit that referenced this pull request Jun 21, 2026
…mantics
PR #30's P2 #3 redefines promptSegment.toolCount as the model-visible
(active) tool count, not the full providerTools registry (which includes
the invalid fallback plus any deferred-unloaded / economy-hidden schemas
that never reach the wire). After rebasing onto Tool Source Economy
(PR #34), three economy tests still asserted the old providerTools.length
values; update them to the active counts, which now equal
modelToolNames().length in each case.
jackwener pushed a commit that referenced this pull request Jun 21, 2026
feat: Layer 1 deferred (on-demand) tool loading — defer heavy schemas via load_tool
jackwener pushed a commit that referenced this pull request Jun 21, 2026
…o ToolAvailabilityRuntime
Collapse the two parallel tool-visibility mechanisms — PR #30 deferred tool
loading and PR #34 tool source economy — into one mechanism and one policy
(issue #37):
- One `economy` switch + configurable `coreToolNames`; grouping comes from
`ToolAvailabilityConfig.groups`, not the per-tool `exposure` tag (removed
from MakaTool). The now-dead `toolSource` tag is swept separately next.
- One `load_tools` connector, built by the runtime. Same-turn activation honors
only `load_tools` (and only its `group` arg). The historical `load_tool`
(PR #30) and `connect_tool_source` (PR #34) names are accepted solely when
re-seeding prior-turn activations from the durable ledger — never advertised,
never live in the current turn.
- One same-turn activation policy (prepareStep + execute-boundary gating);
the next-request economy semantics are dropped.
- Delete tool-source-economy.ts, deferred-activation.ts, load-tool.ts and
their tests; add tool-availability.ts + tool-availability.test.ts.
- Rename ToolSourceEconomyDiagnostic → ToolAvailabilityDiagnostic (record
field toolSourceEconomy → toolAvailability) while keeping the historical
*SourceIds shell vocabulary (a "source" id is a catalog group id).
- Wire main.ts through a single ToolAvailabilityConfig
(economy = !MAKA_DISABLE_DEFERRED_TOOLS; groups = rive/office/browser); the
runtime now produces the connector, so builtinTools no longer include it.
jackwener pushed a commit that referenced this pull request Jun 21, 2026
Recognize the unified `load_tools` connector (and the historical `load_tool`,
PR #30 — it shipped and returns the same `{ loaded: [...] }` shape) for the
localized "Load tools" card, reading the loaded group id from `group` with a
`namespace` fallback so replayed pre-unification sessions still render.
`connect_tool_source` (PR #34) is deliberately not presented: it never shipped,
so no such result exists, and its `{ tools: [...] }` shape differs from this
card's `{ loaded: [...] }`. The runtime still seeds from it for the durable
ledger (a separate concern that reads call args, not results).
jackwener pushed a commit that referenced this pull request Jun 21, 2026
…ive snapshot
The repair callback passed first-step `activeTools` (captured before any
mid-turn load) as the set to match a case-drifted tool name against. Once a
deferred namespace is loaded, `prepareStep` expands the provider-visible tools
for later steps, but the repair list did not follow — so a provider that emitted
a mis-cased loaded deferred name (e.g. `BROWSER_CLICK` after loading `browser`)
no longer matched the canonical tool and was wrongly routed to `invalid`, even
though that tool was advertised in that step.
Follow the current step's active snapshot for the repair set (falling back to
the static active set when no deferred catalog is configured). Adds a live
AiSdkBackend regression: load browser at step 0, emit `BROWSER_CLICK` at step 1,
assert it repairs to canonical `browser_click` and runs instead of routing to
`invalid`.
Addresses the Codex review [P2] on PR #30.
jackwener pushed a commit that referenced this pull request Jun 21, 2026
…catalog path
Addresses two findings from a GPT-Pro review of PR #30.
P2 — same-turn deferred loads were invisible to cost/cache telemetry. send()
computed toolSchemaChars/hash and the request-shape diagnostic once at stream
start from the step-0 active set, but prepareStep expands the provider tool set
on later steps when a family is loaded this turn. So the first turn that called
load_tool under-reported the heavy schema it actually sent on step N+1, and the
cache reset surfaced a turn late. Extract a computeTurnDiagnostics(active)
closure (one source of truth), report the step-0 view in the stream-start trace
(literally what the first request carries), then refine the durable cost record
+ prefix baseline against the final active set once the stream is consumed —
a no-op on the common no-load path (the ratchet only grows the set, so an
unchanged length means nothing loaded). Next turn now classifies as stable
instead of tool_schema_changed.
P3 — the no-catalog path was not self-protecting. canonicalizeToolSet() hides
any exposure:'deferred' tool when called without a loaded-names set, but
AiSdkBackendInput documents an absent deferredCatalog as "every tool advertised".
A deferred tag with no catalog would strand the tool off the wire with no
load_tool to recover it. Make the contract self-enforcing: with no catalog,
seed all deferred tool names as loaded (advertise everything). Document the
loadedDeferredNames contract on canonicalizeToolSet so direct callers aren't
bitten.
Tests (deferred-tools-backend.test.ts): a same-turn load_tool(browser) records
tool-schema cost for the expanded set (Read+load_tool+browser_click), not the
lean step-0 set; a deferred-tagged tool with no catalog is still advertised at
step 0.
jackwener pushed a commit that referenced this pull request Jun 21, 2026
A model firing several tool calls in one step (e.g. browser_snapshot +
browser_extract in parallel) produces multiple permission_request events for one
session. The renderer kept a single pending request per session, so the later
request overwrote the earlier one — the overwritten request could never be
answered, its tool stayed parked forever while run status flipped back to
"running", and the turn hung until the user force-stopped it. Confirmed in two
ON-session ledgers: snapshot's request f2d2b2b7 never got a decision, the user
hit stop, snapshot failed with errorClass "Permission".
Replace the single slot with a per-session FIFO queue: parallel requests all
survive, the user clears them one at a time (queue head = active overlay),
nothing is stranded. Pure helpers (enqueue dedups by requestId, dequeue promotes
the next, clear drops all, activePermissionFor reads the head) live in @maka/ui,
unit-tested including the exact stranded-snapshot scenario.
Surfaced while testing deferred tool loading (PR #30): deferred is only the
trigger — loading a whole group makes the model batch-call it in parallel, which
exposes this pre-existing single-slot bug. Deferred activation itself is correct.
- packages/ui/src/permission-queue.ts: FIFO helpers + barrel export
- apps/desktop/src/renderer/main.tsx: permissionBySession -> queue
- tests: permission-queue.test.ts (6); update complete-case source contract to
assert clearPermissions(...)
jackwener pushed a commit that referenced this pull request Jun 21, 2026
A Codex review of the PR surfaced three advisory (no P1) issues on top of the
parallel-permission-queue fix. Fixed together rather than carried as debt.
1. permission-engine: allow + rememberForTurn now absorbs other already-parked
requests sharing the same scope. browser_* shares one turn-scope, so when a
parallel batch (snapshot + extract + ...) all park and the user answers one
with "remember for this turn", the rest must not each re-prompt.
recordResponse resolves the same-scope parked promises; each tool's own
coroutine emits its own permission_decision_ack, so the UI queue drains
without a second click.
2. renderer: drain a stale permission-queue entry on tool_result. A permission
that ends without a user decision (runtime timeout / expiry) emits a
tool_result, not a permission_decision_ack -- so the FIFO queue could surface
an already-expired request as an un-answerable overlay. The tool_result
handler now dequeues by toolUseId (new dequeuePermissionByToolUseId helper);
no-op on the normal allow/deny path where the ack already dequeued.
3. ai-sdk-backend: telemetry toolCount now counts the active (provider-visible)
tool subset instead of the full registry, matching toolSchemaChars. Under
deferred loading the cost/diagnostic record now reflects the on-demand drop.
Tests: permission-engine (absorb same-scope; don't absorb without remember or
across scopes), permission-queue (dequeueByToolUseId drains + no-ops). Full
typecheck clean; runtime affected suites 72/72; desktop permission + contract
tests green.
jackwener pushed a commit that referenced this pull request Jun 21, 2026
…mantics
PR #30's P2 #3 redefines promptSegment.toolCount as the model-visible
(active) tool count, not the full providerTools registry (which includes
the invalid fallback plus any deferred-unloaded / economy-hidden schemas
that never reach the wire). After rebasing onto Tool Source Economy
(PR #34), three economy tests still asserted the old providerTools.length
values; update them to the active counts, which now equal
modelToolNames().length in each case.
jackwener pushed a commit that referenced this pull request Jun 21, 2026
feat: Layer 1 deferred (on-demand) tool loading — defer heavy schemas via load_tool
jackwener pushed a commit that referenced this pull request Jun 21, 2026
…o ToolAvailabilityRuntime
Collapse the two parallel tool-visibility mechanisms — PR #30 deferred tool
loading and PR #34 tool source economy — into one mechanism and one policy
(issue #37):
- One `economy` switch + configurable `coreToolNames`; grouping comes from
`ToolAvailabilityConfig.groups`, not the per-tool `exposure` tag (removed
from MakaTool). The now-dead `toolSource` tag is swept separately next.
- One `load_tools` connector, built by the runtime. Same-turn activation honors
only `load_tools` (and only its `group` arg). The historical `load_tool`
(PR #30) and `connect_tool_source` (PR #34) names are accepted solely when
re-seeding prior-turn activations from the durable ledger — never advertised,
never live in the current turn.
- One same-turn activation policy (prepareStep + execute-boundary gating);
the next-request economy semantics are dropped.
- Delete tool-source-economy.ts, deferred-activation.ts, load-tool.ts and
their tests; add tool-availability.ts + tool-availability.test.ts.
- Rename ToolSourceEconomyDiagnostic → ToolAvailabilityDiagnostic (record
field toolSourceEconomy → toolAvailability) while keeping the historical
*SourceIds shell vocabulary (a "source" id is a catalog group id).
- Wire main.ts through a single ToolAvailabilityConfig
(economy = !MAKA_DISABLE_DEFERRED_TOOLS; groups = rive/office/browser); the
runtime now produces the connector, so builtinTools no longer include it.
jackwener pushed a commit that referenced this pull request Jun 21, 2026
Recognize the unified `load_tools` connector (and the historical `load_tool`,
PR #30 — it shipped and returns the same `{ loaded: [...] }` shape) for the
localized "Load tools" card, reading the loaded group id from `group` with a
`namespace` fallback so replayed pre-unification sessions still render.
`connect_tool_source` (PR #34) is deliberately not presented: it never shipped,
so no such result exists, and its `{ tools: [...] }` shape differs from this
card's `{ loaded: [...] }`. The runtime still seeds from it for the durable
ledger (a separate concern that reads call args, not results).
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@Astro-Han