Uh oh!
There was an error while loading. Please reload this page.
feat: Layer 1 deferred (on-demand) tool loading — defer heavy schemas via load_tool - #30
Merged
Conversation
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-Hanforce-pushed
the
claude/deferred-tools
branch
from
June 17, 2026 03:28
ff9c482 to
dfa5ad7CompareAstro-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-Hanforce-pushed
the
claude/deferred-tools
branch
from
June 17, 2026 07:00
043cfc3 to
91f7e79CompareAstro-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).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_toolcatalog, 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.
exposure?: 'direct' | 'deferred'field onMakaTool(defaultdirect).providerToolsstays the full registry for dispatch; the model-visibleactiveToolsset isdirect ∪ {load_tool} ∪ loaded-deferred. The AI SDK serializes onlyactiveToolsto the provider (verified againstai@6.0.185source —prepareToolsAndToolChoicefilterstoolsbyactiveTools, only the filtered array reachesdoStream), 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 viaactiveTools.prepareStep.streamTextis handedactiveToolsonce and re-filters every internal step against that frozen array, so withoutprepareStepa tool loaded at step N stays invisible until the next user turn. The backend now installs aprepareStepthat recomputesactiveToolsbefore each step from the step history it is handed (steps[].toolCallsforload_tool) — stateless, no race with tool execution. A tool loaded at step N is advertised at step N+1 of the same turn.ToolRuntimeholds a per-step active snapshot (fed by the backend fromprepareStep'sonActiveSnapshot) and rejects a deferred tool absent from it — after theToolCallMessage(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 emitsload_tool(browser)+browser_clickin parallel,browser_clickis rejected (browser activates only at the next step'sprepareStep). This closes the known AI SDKactiveToolsleak (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.seedNamespacesFromRuntimeEventsscans committedload_toolfunction_callevents), 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.toolSchemaHash/toolSchemaCharsForDiagnosticsnow 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-firetool_schema_changed. A genuine load still moves the hash and is labeledtool_schema_changed: one bounded prompt-cache reset the first time each family loads per session, then a leaner and stable prefix.main.tstags the heavy families (RiveWorkflow, OfficeDocument×2, the 6browser_*tools)deferred, builds the 3-namespace catalog from the real tool names, registersbuildLoadTool(catalog), and threadsdeferredCatalogintoAiSdkBackend.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:
prepareStepis mandatory for same-turn use (the frozen-activeToolstrap above).load_toolreturns the thin{loaded}result, never the schema (no double-billing).providerTools.load_tool(x)+xtrap.Deliberate scope
load_toolstay always-on. Explore stays direct (single + frequent). Confirmed by measurement (below) — the heavy families are 8,570 of 12,358 schema chars.defer_loading/ OpenAItool_searchnatives — Maka spans DeepSeek / Ollama / openai-compatible / Gemini / Anthropic, so gating happens inactiveTools(which all adapters honor) rather than a provider feature.MAKA_DISABLE_DEFERRED_TOOLS→ all tools direct, noload_tool, inert guard), not a gradual rollout.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.tsincluded).network/proxy-test("times out when the proxy accepts TCP but never responds" asserts/timeout/i, but in this sandboxfetchreturns "fetch failed" before the timeout fires). This PR touches no network code.New tests (33) covering every slice:
request-shape.test.ts, 4): deferred excluded fromactiveToolsuntil loaded;providerToolsstays full;invalidnever advertised; omitted exposure = direct.deferred-tools-wire.test.ts, 2): aMockLanguageModelV3throughModelAdapter.startStreamproves an unloaded deferred tool never reachesdoStream.load_tool(load-tool.test.ts, 4): valid namespace →{loaded}; unknown rejected; no schema in the result.deferred-tools-prepare-step.test.ts, 1): end-to-end — a tool loaded at step 0 reaches the provider at step 1.deferred-activation.test.ts, +):loadedNamespacesFromSteps,seedNamespacesFromRuntimeEvents,computeActiveTools,buildDeferredPrepareStep.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).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.deferred-tools-backend.test.ts, 7): step 0 hides the unloaded deferred tool but advertisesload_tool; a prior-turn load re-advertises it next turn (durable seed); same-step parallelload_tool(browser)+browser_clickrejects the click through the realAiSdkBackend; a mis-casedBROWSER_CLICKemitted the step afterload_tool(browser)repairs to canonicalbrowser_clickand runs; a same-turnload_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 recordedrequestShapeHashacross a same-turn load; a deferred-tagged tool with no catalog stays advertised at step 0.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
activeTools, so a mis-cased loaded deferred name would route toinvalidinstead of repairing. Fixed infd9445e1: the repair set now follows the current step's active snapshot.load_toolunder-reported the heavy schema actually sent on step N+1 and surfaced the cache reset a turn late. Fixed inff9c4827: onecomputeTurnDiagnostics(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).canonicalizeToolSet()hid anyexposure:'deferred'tool when called without a loaded-names set, but an absentdeferredCatalogis documented as "advertise everything". A deferred tag with no catalog would have stranded the tool with noload_toolto recover it. Fixed inff9c4827: with no catalog the backend seeds all deferred names as loaded, and thecanonicalizeToolSetcontract is now documented.main(which added a context-budget high-water block),highWaterRequestShapeHashAfterwas 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 inc1ec3404: a singlepublishTurnDiagnostics()updates the cost record, the prefix baseline, and the high-water "after" hash together; the "before" hash stays the pre-turn baseline.load_toolsurfaced as its raw name + raw JSON result. Given a friendly, locale-aware presentation (display name + result card, following the zh/en UI setting) in38552cae— 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_snapshotappear to "fail" whenever deferral was ON. Reading two ON-session RuntimeEvent ledgers gave the real cause: afterload_tool(browser)the model (deepseek) batch-calledbrowser_snapshot+browser_extractin parallel, producing twopermission_requestevents 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 torunning— and the turn hung until the user force-stopped it (browser_snapshotthen failed witherrorClass: "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
49bacf23by 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; themain.tsxcomplete-case source contract updated to assertclearPermissions(...).Codex review (
/codex review, xhigh) — three P2 advisories, all fixed in043cfc32:rememberForTurndidn'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.recordResponsenow resolves the same-scope parked promises; each tool's coroutine emits its own ack and the UI queue drains without a second click.tool_result, not apermission_decision_ack, so the FIFO queue kept the stale entry. The renderer'stool_resulthandler now dequeues bytoolUseId(dequeuePermissionByToolUseId); no-op on the normal ack path.toolCountstill counted the full registry. Now counts the active (provider-visible) subset, matchingtoolSchemaChars, 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):
The
load_toolcatalog 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.)