Skip to content

feat(agent): dynamic tool calling on what's available — one availability-gated palette for both shells - #201

Merged
kpmmmurphy merged 3 commits into
masterfrom
feat/tool-palette
Sep 4, 2026
Merged

feat(agent): dynamic tool calling on what's available — one availability-gated palette for both shells#201
kpmmmurphy merged 3 commits into
masterfrom
feat/tool-palette

Conversation

@kpmmmurphy

Copy link
Copy Markdown
Contributor

Why

Kev, after the phone-vs-Mac voice read (2026-09-03): "how do we lean out on Mac? We want consistency — by dynamic tool calling on what's available." Each shell hand-built its own tool list, and the Mac's carried tools whose backing might not be there: open_link with the web toggle off (a page render reaches the internet too), delegate_deep on a machine whose dive never reaches Big, battery_status on a desktop, the knowledge trio over an empty corpus.

What

  • ToolPalettePolicy (M1K3Chat, pure, TDD red-first — 8 tests including a live-name pin): Availability{corpusHasItems, webAllowed, deepBrainAvailable, hasBattery}excludedNames / filter. Names are strings for the same reason SelfQueryGate's are; the pin guards drift.
  • Mac — applied inside interactiveAgentTools, the one builder the launch warm and the live turn share, so the warmed persona prefix can never drift from the turn's set. deepBrainAvailable reads the same gate the dive handler uses (DeepDiveTarget.plan over LocalModelInventory + physical RAM); hasBattery is the IOKit probe, read once. open_link now rides the web toggle, so the "only thing that reaches the internet" guarantee holds.
  • iOS — the responder's list passes the same policy (corpus + web; this shell offers no bigger brain and no battery tool).
  • DocsCONTEXT_TOOLS_PLAN.md, ROADMAP.md.

What this deliberately is NOT

Per-question routing. The palette is a PersonaPrefixCache key: a different tool set is a ~6 s cold prefill against a 2-entry cache (ROADMAP: "tune the grounding, never the palette"). So the inputs are stable facts only — a toggle, a download, a first document — never the query. If Kev's felt A/B says the Mac still carries too much, the next lever is the prefix-cache shape (capacity, or tools rendered after the cached persona), which is a measured decision, not a flip.

Product choice worth a glance

delegate_deep leaves the palette on a Mac where the dive would only run on the resident brain ("buying time, not extra intelligence" — the tool's own description). That's the literal reading of "on what's available"; the affordance is one toggle-free download away (Big) and comes back on the next turn. Easy to revert if the buy-time dive is wanted without Big.

Tests / verification

  • M1K3ChatTests 524 green (+8).
  • Mac app + iOS simulator shells build.
  • Verify-owed: Kev's felt A/B — phone vs Mac over voice on real devices (the shoot-off builds are cut from this branch).

🤖 Generated with Claude Code

https://claude.ai/code/session_01K2kJAPEHTFU34uKiGArFSP

…ity-gated palette for both shells

Kev, after the phone-vs-Mac voice read (2026-09-03): "how do we lean out on
Mac? We want consistency — by dynamic tool calling on what's available."
The Mac and the phone each hand-built their own tool list; the Mac's carried
tools whose backing might not be there (open_link with the web toggle off,
delegate_deep on a machine whose dive never reaches Big, battery_status on a
desktop, the knowledge trio over an empty corpus).

- ToolPalettePolicy (M1K3Chat, pure, TDD red-first, 8 tests incl. a
  live-name pin): Availability{corpusHasItems, webAllowed, deepBrainAvailable,
  hasBattery} → excludedNames / filter. Names are strings for the same reason
  SelfQueryGate's are; the pin guards drift.
- Mac: applied INSIDE interactiveAgentTools, the builder the launch warm and
  the live turn share, so the warmed prefix can't drift from the turn's set.
  deepBrainAvailable reads the SAME gate the dive handler uses
  (DeepDiveTarget.plan over LocalModelInventory + physical RAM); hasBattery is
  the IOKit probe, read once. open_link now rides the web toggle — a page
  render reaches the internet too, so the "only thing that reaches the
  internet" guarantee holds.
- iOS: the responder's list passes the same policy (corpus + web; this shell
  offers no bigger brain and no battery tool).
- Deliberately NOT per-question routing: the palette is a PersonaPrefixCache
  key (a different set = ~6 s cold prefill, 2-entry cache — ROADMAP's "tune
  the grounding, never the palette"). Stable facts only. Docs updated
  (CONTEXT_TOOLS_PLAN, ROADMAP).

Tests: M1K3ChatTests 524 green (+8). Mac app + iOS simulator shells build.
Verify-owed: Kev's felt A/B, phone vs Mac over voice on real devices.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K2kJAPEHTFU34uKiGArFSP

# ── MurphySig template (uncomment lines below to sign this commit) ──────
# Significant change detected. If this commit encodes a decision worth
# remembering — architecture, tradeoff, non-obvious fix — sign it.
# Spec: https://murphysig.dev/spec
#
# MurphySig:
#   Signed: Kev + claude-fable-5-1, 2026-09-03
#   Format: MurphySig v0.1
#   Context: <why this exists — the thought, not the diff>
#   Confidence: <0.0-1.0> — <what's uncertain>
#   Open: <unresolved questions>
# ────────────────────────────────────────────────────────────────────────
@netlify

netlify Bot commented Sep 3, 2026

Copy link
Copy Markdown

Deploy Preview for m1k3 canceled.

Name Link
🔨 Latest commit 0eeed98
🔍 Latest deploy log https://app.netlify.com/projects/m1k3/deploys/6a9b04cb072fb90008992297

@claude

claude Bot commented Sep 3, 2026

Copy link
Copy Markdown

Review — dynamic tool palette (ToolPalettePolicy)

Nice piece of work overall: the policy is a clean pure/testable seam (M1K3Chat only pulls in M1K3Agent, same pattern as SelfQueryGate), the namesMatchLiveTools pin is a good guard against a tool rename silently un-gating something, and the delegate_deep gate correctly mirrors DeepDiveTarget.plan's handling of 'resident is already Big' (early-return before calling plan, matching plan's own resident != .big guard so the semantics can't drift). The open_link-now-rides-the-web-toggle fix is a real, deliberate behavior correction (previously it was added whenever onOpenLink was non-nil, regardless of the toggle) and is well justified by the PR body's privacy framing.

1. New synchronous I/O now runs on @MainActor at the warm/reload path

AppEnvironment.warmPersonaPrefixAfterLoad(_:) (macos/M1K3App/AppEnvironment.swift:1290) is an instance method on @MainActor final class AppEnvironment — it is not nonisolated. It calls Self.interactiveAgentTools(...) synchronously, twice (once for headlessTools, once for interactiveTools, AppEnvironment.swift:1312 and :1316) before handing off to the Task.detached block that does the actual MLX warm work.

Before this PR, interactiveAgentTools only did cheap UserDefaults reads. This PR adds real I/O inside it (macos/M1K3App/AppEnvironment+ChatHistory.swift:259-288):

  • store.itemCount() — a GRDB dbQueue.read (blocking SQLite call)
  • Self.deepBrainAvailable()UserDefaults + LocalModelInventory().isInstalled(modelID:), which does a real FileManager.default.contentsOfDirectory disk listing
  • Self.hasBattery — an IOKit power-source query (LiveBatteryHealthProvider().healthSnapshot()), memoized as a static let, so whichever call site touches it first process-wide pays the syscall inline

Because interactiveAgentTools is nonisolated but called without await from a @MainActor-isolated method, there's no actor hop — it runs directly on the main thread. So warmPersonaPrefixAfterLoad (fired right after a model load completes, and again after a deep-dive restore in AppEnvironment+DeepDelegation.swift:232) now does two DB reads + two directory listings (+ possibly one IOKit call) on the main thread, exactly at a moment this file's own doc comment frames as 'best-effort... in the background.'

Each individual call is probably sub-millisecond in practice, but it's a real regression against the stated principle ('no blocking the main actor during a model load') and it's paid twice for no reason — headlessTools and interactiveTools recompute the identical Availability facts independently. Suggest either:

  • computing Availability once and threading it through both interactiveAgentTools calls (also fixes the redundant I/O), and/or
  • moving the two interactiveAgentTools(...) calls inside the existing Task.detached block so the new I/O doesn't land on the main thread at all.

2. Minor nits (non-blocking)

  • AppEnvironment+ChatHistory.swift:260 and M1K3iOSApp/AppCore.swift:605: ((try? store.itemCount()) ?? 1) > 0 — the ?? 1 fail-open-on-DB-error default is a little cryptic and duplicated verbatim across both shells. A one-line comment on why a failed count should fail open (keep the knowledge tools rather than hide them) would match this file's habit of justifying every default; consider hoisting into a small shared helper on ToolPalettePolicy since both shells now compute the same four facts from the same primitives.
  • M1K3iOSApp/AppCore.swift:604-609: passing hasBattery: true reads a little oddly next to the comment 'this shell offers no battery tool' — it's correct (iPhones do have batteries, and BatteryStatusTool is never added to tools here so the value is currently a no-op), but a future reader might trip on the apparent mismatch. Worth a short note that the value is inert until/unless a battery tool is ever wired into the iOS palette.

Swift 6 / architecture / provenance — clean

  • Sendable/Equatable on Availability is correct and minimal; no @unchecked Sendable needed anywhere in the new code.
  • No heavy deps leaked into M1K3Knowledge/M1K3ChatLocalModelInventory (M1K3MLX) and LiveBatteryHealthProvider (M1K3AgentTools, already imported) stay in app-target glue, consistent with the module boundaries in macos/CLAUDE.md.
  • Prior: none (new file) on ToolPalettePolicy.swift matches the repo's established convention for new files (not Prior: Unknown, which is reserved for attic-derived/ported content) — fine as-is.
  • Test coverage is solid for the pure policy (8 cases, composition, order-preservation, and the drift pin); the app-glue combinators (deepBrainAvailable(), hasBattery) are correctly left untested per the TDD_SKIP doctrine for M1K3App/.

…the #201 review fold

claude-review's one real finding: the availability facts are real I/O (a
SQLite count, a directory listing, IOKit once) and the warm path called the
builder twice, synchronously, on the main actor. Now:
- interactiveAgentTools takes an optional `availability:`; nil computes live
  (the per-turn path). New nonisolated helpers `paletteAvailability(store:)`
  and `webSearchAllowed()` so both the builder and the warm read the same
  facts the same way.
- warmPersonaPrefixAfterLoad builds both palettes INSIDE its detached task,
  from one Availability — no main-actor I/O, no double read.
- ToolPalettePolicy.corpusHasItems(count:) hoists the fail-open default out
  of both shells, documented (an unreadable count keeps the tools), red-first
  pinned (nil → true, 0 → false, 3 → true).
- iOS: the inert `hasBattery: true` says why it's inert.

Tests: ToolPalettePolicyTests 9 green. Mac app + iOS simulator shells build.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K2kJAPEHTFU34uKiGArFSP

# ── MurphySig template (uncomment lines below to sign this commit) ──────
# Significant change detected. If this commit encodes a decision worth
# remembering — architecture, tradeoff, non-obvious fix — sign it.
# Spec: https://murphysig.dev/spec
#
# MurphySig:
#   Signed: Kev + claude-fable-5-1, 2026-09-03
#   Format: MurphySig v0.1
#   Context: <why this exists — the thought, not the diff>
#   Confidence: <0.0-1.0> — <what's uncertain>
#   Open: <unresolved questions>
# ────────────────────────────────────────────────────────────────────────
# Conflicts:
#	macos/M1K3iOSApp/AppCore.swift
kpmmmurphy added a commit that referenced this pull request Sep 4, 2026
…proval window 5 s → 60 s (#192) (#203)

Five hits in three days (latest: PR #201, two attempts in a row, 5.6–6.8 s
each). The scripted transport needs three 1 ms polls; the 5 s approvalWindow
is only a deadline — but under `swift test --parallel` on the CI runner the
actor hops alone can exceed it, and the ceremony then honestly reports "not
approved". Sixty seconds keeps the deadline as a hang guard without racing
the scheduler. Test-only change.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K2kJAPEHTFU34uKiGArFSP

# ── MurphySig ──────────────────────────────────────────────────────
# Significant decision in this commit? Sign it. https://murphysig.dev/spec
# ───────────────────────────────────────────────────────────────────
@claude

claude Bot commented Sep 4, 2026

Copy link
Copy Markdown

Review — #201 (dynamic tool-calling palette)

Went through both commits (ecf8b818 initial, 0f19d8cf the main-actor-I/O
review fold) plus the merge. This is clean work — the fold commit already
fixed the one real concurrency issue (building the two warm palettes
synchronously on the main actor, doing SQLite/directory/IOKit I/O inline).
Verified the current state:

Concurrency

  • Task.detached(priority: .utility) { [weak mlx, store, embedder, deepDelegationHook] in ... } (AppEnvironment.swift:1315) now computes Availability once inside the detached task and threads it into both interactiveAgentTools calls — no double I/O, off the main actor. store (KnowledgeStore: @unchecked Sendable), embedder (SwappableEmbeddingService: Sendable), deepDelegationHook (DeepDelegationHook: Sendable) are all safe to capture.
  • hasBattery (AppEnvironment+ChatHistory.swift:304) is a nonisolated static let — Swift's static-let init is thread-safe and lazy, so "read once" is actually true, not just documented as such.
  • itemCount() (KnowledgeStore.swift:258) goes through dbQueue.read, so the live per-turn paletteAvailability call (interactiveAgentTools's nil-availability path, AppEnvironment+ChatHistory.swift:262) is safe off any particular actor. toolsProvider is invoked once per turn (AgentRAGResponder.swift:330), not per tool-loop iteration, so the added SQLite count + LocalModelInventory directory check per turn is negligible.

Architecture

  • ToolPalettePolicy sits in M1K3Chat, which already depends on M1K3Agent (Package.swift:349) — no new/heavy dependency introduced, and the policy itself only imports M1K3Agent for the AgentTool protocol. Pure, no MLX/GRDB leakage.
  • String-based tool names, following SelfQueryGate's existing pattern for the same reason (module can't link the tool targets). The namesMatchLiveTools pin (ToolPalettePolicyTests.swift) checks out — every literal in knowledgeToolNames/webToolNames/deepBrainToolNames/batteryToolNames matches the actual public let name = "..." declarations in M1K3KnowledgeTools/M1K3AgentTools.

Correctness

  • open_link now correctly rides the web toggle: it's still unconditionally appended when onOpenLink is non-nil (AppEnvironment+ChatHistory.swift:218-220), but the ToolPalettePolicy.filter pass afterward removes it when !webAllowed. Confirmed against the webOff test — matches the PR's stated fix.
  • deepBrainAvailable() mirrors DeepDiveTarget.plan inputs (resident, bigWeightsPresent, physicalMemoryGB) the same way the real handler in AppEnvironment+DeepDelegation.swift:115 does. One asymmetry worth naming (not a bug): the handler's actual dive additionally gates on DeepDelegationPolicy.eligibility (model-load state, AFM availability) before DeepDiveTarget.plan even runs, while the palette gate only mirrors plan. That's fine — a delegate_deep call that's offered but still ineligible already has an existing refusal path (declineReason/refusalObservation) — but if a reviewer expected "offered ⇒ eligible" this is the gap.
  • LocalModelInventory() is instantiated fresh in deepBrainAvailable() rather than reusing AppEnvironment.brainInventory (used by isBrainDownloaded) — confirmed harmless since LocalModelInventory is a stateless Sendable struct with no caching, just a different instance doing the same disk check.
  • corpusHasItems(count:)'s fail-open default (nil → true) is well-reasoned and pinned (corpusFactFailsOpen test) — an unreadable count keeps the tools rather than silently hiding capability.

Tests ✅ — 9/9 new tests read as meaningful, not padding: each exclusion reason gets its own test, compose checks additivity (9 = 3+4+1+1), filterKeepsOrderAndUnknowns checks both order preservation and pass-through of ungoverned tool names, and the live-name pin guards the whole thing against silent drift on a tool rename.

Nit (non-blocking): the iOS-side web-toggle check (AppCore.swift:635-636) duplicates AppEnvironment.webSearchAllowed()'s logic verbatim rather than sharing it — understandable since it's two different app targets, not a shared package, but if a third shell ever appears this duplicated UserDefaults key logic is worth hoisting into a shared, pure M1K3Chat-level helper (mirroring what ToolPalettePolicy itself already did for the exclusion rule).

Provenance — new file signed Prior: none (new file, patterned on SelfQueryGate), consistent with the existing convention elsewhere in the tree (e.g. WakeSetup.swift, BrainUpgradePolicy.swift). No fabricated authorship.

Nothing blocking. Good example of applying the fix inside the one shared builder so the warm and the live turn structurally can't drift.

@kpmmmurphy
kpmmmurphy merged commit 4c177be into master Sep 4, 2026
20 checks passed
@kpmmmurphy
kpmmmurphy deleted the feat/tool-palette branch September 4, 2026 18:08
Sign up for free to 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