feat: local-first LLM routing with reactive Opus escalation #198

Open
KofTwentyTwo wants to merge 10 commits from feat/local-first-llm-routing into develop
pull from: feat/local-first-llm-routing
10 Commits
Author SHA1 Message Date
james.maesandClaude Opus 4.7 2f2e9edf9a feat(settings): provider mode picker — Local-first / Anthropic only / Ollama only
Three-way picker maps to {provider, escalationEnabled} pairs. Wired
to ConfigStore for hot-reload. Local-first is the new default
(default-config.json).

- ProviderMode enum (Local-first / Anthropic only / Ollama only) +
  bidirectional mapping to {provider, escalationEnabled}.
- New Provider section in SettingsView, hydrated from ConfigStore
  on .onAppear, persists through ConfigStore.updatePerTurn(_:) on
  .onChange.
- PerTurnSnapshot.with(provider:escalationEnabled:) — multi-field
  copy so the picker can mutate both fields atomically.
- ConfigStore threaded through SettingsWindowController.open(...)
  from AppDelegate.openSettings().
- ProviderModeTests covers canonical mapping, (anthropic, true)
  edge case normalization, and round-trip via .pair.

Refs design spec §6.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 16:51:03 -05:00
james.maesandClaude Opus 4.7 9ff05be0a2 feat(hud): EscalationBadge — renders ollama→anthropic transition reason
When a chat turn is escalated, the bus client attaches the
EscalationDecision to the most-recent assistant message; the message
renderer shows an inline badge with friendly reason text. Six
OllamaFailureKind reasons mapped to plain English.

If escalation fires before any assistant tokens have streamed (e.g.
connectionFailure on the very first response), the decision is parked
on the store as pendingEscalation and stamped onto the first text part
created by a subsequent tokenDelta — so the badge is never dropped.

Refs design spec §4.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 16:33:41 -05:00
james.maesandClaude Opus 4.7 a7aae9df77 feat(bus): add BusOutbound.escalated event for HUD escalation badge
Local-first LLM routing Task 7. Carries an EscalationDecision from the
orchestrator to the HUD so the escalation badge can render the
ollama->anthropic transition + reason. BusForwarder gains an .escalated
arm that routes the AgentCore-side decision through a new
BusForwarderSink.sendEscalated hook; AppBusForwarderSink translates the
AgentCore types into the dependency-free Bus mirror types
(BusEscalationDecision / BusProviderSelection / BusOllamaFailureKind),
preserving the lightweight-bridge-layer invariant.

Bus protocol bumped 2.3.0 -> 2.4.0 (MINOR, additive). TS decoder gains
matching guards; HUD client adds a no-op acknowledgment for the
exhaustiveness sentinel (the EscalationBadge component lands in Task 8).

Round-trip coverage:
- packages/Bus: fixture-driven Codable round-trip + new BusForwarder
  tests asserting sendEscalated fires without spurious lifecycle events.
- webview/packages/bus: fixture parity + protocol-version test bumped.

Refs spec docs/superpowers/specs/2026-05-14-local-first-llm-routing-design.md §4.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 16:21:38 -05:00
james.maesandClaude Opus 4.7 038e35152b feat(orchestrator): reactive escalation from ollama to anthropic
Extend the per-turn retry hook to handle OllamaFailureKind-bearing
provider errors via providerError.ollamaFailureKind. When the local
Ollama provider emits a malformedToolCall / emptyResponse on a turn
with escalationEnabled, the orchestrator allocates a fresh turn row
(retryOf the original), swaps to Anthropic, and re-runs the turn from
initialMessages. Enforces at-most-one escalation per turn — a second
provider failure surfaces a terminal `.error` event. The
`escalatedThisTurn` flag is local to `runTurnLoop` so it resets across
turns naturally (no sticky cross-turn state).

Adds:
- `OrchestratorEvent.escalated(EscalationDecision)` — surfaced once
  per escalation; Task 7 wires this to BusOutbound for the HUD badge.
- `PerTurnSnapshot.with(provider:)` — convenience copy used by the
  orchestrator to swap providers per-turn without mutating ConfigStore.

The existing AGENT-09 streamTruncated retry (in `.stopReason`) is left
parallel; it triggers on a different LLMEvent shape (transport-mid-
stream EOF) and was independently validated by OrchestratorRetryTests.

Refs design spec §2, §3. Tests: LocalFirstEscalationTests A/B/C
(reactive fire-once / budget honored / disabled passthrough).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 15:48:59 -05:00
james.maesandClaude Opus 4.7 9ddc32548e feat(ollama): detect malformedToolCall and emptyResponse failure kinds
Wire detection into the NDJSON parser. Adds two new LLMProviderError
cases (`.malformedToolCall(reason:)`, `.emptyResponse`) and an
`ollamaFailureKind: OllamaFailureKind?` accessor so the orchestrator
(Task 6) can route to Anthropic on these failure modes.

NDJSONDecoder tracks `sawAnyTextDelta` / `sawAnyToolUseRequested`; on
the `done:true` terminator it emits `.providerError(.emptyResponse)`
before `.messageStop` when neither was observed. The tool_calls path
now validates string-form `arguments` via `JSONSerialization` and
emits `.malformedToolCall(reason:)` on parse failure instead of
silently forwarding garbage bytes.

OpenAI-compat path is scoped to follow-up — `/api/chat` is the
default transport per CLAUDE.md and where reactive escalation lands
first.

Refs design spec §2.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 15:18:53 -05:00
james.maesandClaude Opus 4.7 e9a54eedd4 feat(config): default to local-first (ollama + escalation enabled)
Closes the policy half of the local-first routing spec. Pinned-anthropic
and pinned-ollama remain valid via Settings (Task 9). Memory extraction
and Anthropic API key wiring untouched.

Also updates ConfigLoaderTests.test_loadDefaultConfigFromBundle to
assert the new ollama default (it asserted .anthropic against the same
bundled resource — a mirror of the policy being flipped).

Refs design spec §1, §6.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 14:45:08 -05:00
james.maesandClaude Opus 4.7 3a825009ff test(config): assert escalationEnabled survives encode-decode round-trip
Guard against a future regression where a custom encode(to:) drops the
field. Today encoder symmetry holds via Swift's Codable synthesis;
this test pins it.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 14:41:17 -05:00
james.maesandClaude Opus 4.7 e268688d56 feat(config): add escalationEnabled field to PerTurnSnapshot
Defaults to true. Custom decoder uses decodeIfPresent so older configs
without the field opt into the new local-first behavior on upgrade.
Init parameter default keeps existing call sites source-compatible.

Refs design spec §6.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 14:37:19 -05:00
james.maesandClaude Opus 4.7 11169d19eb test(agent-core): tighten OllamaFailureKindTests — allCases + full struct round-trip
Code-quality review feedback. Loop OllamaFailureKind.allCases so adding
a case fails the test by construction. Strengthen EscalationDecision
test to assert whole-struct equality and firedAt round-trip.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 14:33:41 -05:00
james.maesandClaude Opus 4.7 48dc664ead feat(agent-core): add OllamaFailureKind enum + EscalationDecision struct
Closed enum of six detectable Ollama failure modes that trigger reactive
escalation to Anthropic per the local-first routing spec. EscalationDecision
is the record emitted once per escalated turn.

Refs design spec §2-§3.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 14:29:41 -05:00