diff --git a/App/AppBusForwarderSink.swift b/App/AppBusForwarderSink.swift
index 09f58c5..cbf817c 100644
--- a/App/AppBusForwarderSink.swift
+++ b/App/AppBusForwarderSink.swift
@@ -1,6 +1,8 @@
import Foundation
+import AgentCore // EscalationDecision (AgentCore type)
import AgentOrchestrator // BusForwarder, BusForwarderSink
-import Bus // OutboundBatcher, BusOutbound, TurnTerminator
+import Bus // OutboundBatcher, BusOutbound, TurnTerminator, EscalationDecision (Bus mirror)
+import Config // ProviderSelection (AgentCore-side)
/// App-side adapter that wires `BusForwarder` (pure logic, lives in
/// AgentOrchestrator for unit-testability) to `OutboundBatcher` (the
@@ -36,6 +38,21 @@ struct AppBusForwarderSink: BusForwarderSink {
try? await batcher.flushAndSend(.submitRejected(reason: reason))
}
+ /// Local-first LLM routing Task 7 — translate the AgentCore-side
+ /// `EscalationDecision` (which carries `Config.ProviderSelection` +
+ /// `AgentCore.OllamaFailureKind`) into the Bus wire-format
+ /// `Bus.EscalationDecision` (which mirrors those enums in the
+ /// dependency-free bridge layer) and flush as `BusOutbound.escalated`.
+ func sendEscalated(decision: AgentCore.EscalationDecision) async {
+ let wire = Bus.EscalationDecision(
+ from: Self.wireProvider(from: decision.from),
+ to: Self.wireProvider(from: decision.to),
+ reason: Self.wireFailureKind(from: decision.reason),
+ firedAt: decision.firedAt
+ )
+ try? await batcher.flushAndSend(.escalated(wire))
+ }
+
/// 1:1 mapping from `BusForwarder.Terminator` (protocol-agnostic, lives
/// in AgentCore) to `Bus.TurnTerminator` (wire format). Both enums must
/// stay in sync; the cases are deliberately identical so each call site
@@ -49,4 +66,29 @@ struct AppBusForwarderSink: BusForwarderSink {
case .superseded: return .superseded
}
}
+
+ /// 1:1 mapping from `Config.ProviderSelection` (Swift-side authority) to
+ /// `Bus.BusProviderSelection` (wire-format mirror). Bus deliberately
+ /// does NOT depend on Config — the exhaustive switch is the
+ /// compile-time drift catcher when either enum adds a case.
+ static func wireProvider(from p: ProviderSelection) -> BusProviderSelection {
+ switch p {
+ case .anthropic: return .anthropic
+ case .ollama: return .ollama
+ }
+ }
+
+ /// 1:1 mapping from `AgentCore.OllamaFailureKind` to
+ /// `Bus.BusOllamaFailureKind`. Same dependency-isolation rationale as
+ /// `wireProvider`.
+ static func wireFailureKind(from k: OllamaFailureKind) -> BusOllamaFailureKind {
+ switch k {
+ case .streamTruncated: return .streamTruncated
+ case .malformedToolCall: return .malformedToolCall
+ case .unknownTool: return .unknownTool
+ case .refusal: return .refusal
+ case .connectionFailure: return .connectionFailure
+ case .emptyResponse: return .emptyResponse
+ }
+ }
}
diff --git a/App/AppDelegate.swift b/App/AppDelegate.swift
index 439ea5a..43a8443 100644
--- a/App/AppDelegate.swift
+++ b/App/AppDelegate.swift
@@ -3321,7 +3321,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
// `WizardState.pttHotkey` is already persisted; rebind
// PushToTalk so the new shortcut takes effect immediately.
self?.rebindPTTFromState()
- }
+ },
+ // Local-first LLM routing (Task 9 / spec §6): pass the live
+ // ConfigStore so the provider-mode picker can hydrate from /
+ // hot-reload-write to the per-turn snapshot.
+ configStore: configStore
)
}
diff --git a/App/Resources/webview/index.html b/App/Resources/webview/index.html
index f00c335..61e2600 100644
--- a/App/Resources/webview/index.html
+++ b/App/Resources/webview/index.html
@@ -5,7 +5,7 @@
Jarvis HUD
-
+
diff --git a/App/Settings/SettingsView.swift b/App/Settings/SettingsView.swift
index cb9bdbd..6296485 100644
--- a/App/Settings/SettingsView.swift
+++ b/App/Settings/SettingsView.swift
@@ -1,6 +1,54 @@
import SwiftUI
import Keychain
import Shell
+import Config
+
+/// Local-first LLM routing (Task 9 / spec §6): three-way picker that maps
+/// to `{provider, escalationEnabled}` pairs on `PerTurnSnapshot`.
+///
+/// | Mode | provider | escalationEnabled |
+/// |------------------|------------|--------------------------|
+/// | Local-first | .ollama | true |
+/// | Anthropic only | .anthropic | false (moot, pinned) |
+/// | Ollama only | .ollama | false |
+///
+/// `escalationEnabled` is "moot" for `.anthropic` because escalation is
+/// an Ollama→Anthropic-only fallback path; we normalize it to `false` so
+/// the persisted state is unambiguous.
+enum ProviderMode: String, CaseIterable, Identifiable {
+ case localFirst
+ case anthropicOnly
+ case ollamaOnly
+
+ var id: String { rawValue }
+
+ var label: String {
+ switch self {
+ case .localFirst: return "Local-first (recommended)"
+ case .anthropicOnly: return "Anthropic only"
+ case .ollamaOnly: return "Ollama only"
+ }
+ }
+
+ static func from(
+ provider: ProviderSelection,
+ escalationEnabled: Bool
+ ) -> ProviderMode {
+ switch (provider, escalationEnabled) {
+ case (.ollama, true): return .localFirst
+ case (.anthropic, _): return .anthropicOnly
+ case (.ollama, false): return .ollamaOnly
+ }
+ }
+
+ var pair: (provider: ProviderSelection, escalationEnabled: Bool) {
+ switch self {
+ case .localFirst: return (.ollama, true)
+ case .anthropicOnly: return (.anthropic, false)
+ case .ollamaOnly: return (.ollama, false)
+ }
+ }
+}
/// Real Settings panel — single window, three rows, every row independently
/// editable. Distinct from the first-run wizard:
@@ -28,6 +76,11 @@ struct SettingsView: View {
/// this still compile.
let onPTTHotkeyChanged: (Shell.KeyboardShortcut?) -> Void
+ /// Local-first LLM routing (Task 9 / spec §6): optional because the
+ /// SwiftUI preview/test seams may instantiate the view without a live
+ /// `ConfigStore`. When nil, the provider-mode section is hidden.
+ let configStore: ConfigStore?
+
let onClose: () -> Void
init(
@@ -37,6 +90,7 @@ struct SettingsView: View {
onGrantInputMonitoring: @escaping () -> Bool,
onHotkeyChanged: @escaping (Shell.KeyboardShortcut?) -> Void,
onPTTHotkeyChanged: @escaping (Shell.KeyboardShortcut?) -> Void = { _ in },
+ configStore: ConfigStore? = nil,
onClose: @escaping () -> Void
) {
self.state = state
@@ -45,6 +99,7 @@ struct SettingsView: View {
self.onGrantInputMonitoring = onGrantInputMonitoring
self.onHotkeyChanged = onHotkeyChanged
self.onPTTHotkeyChanged = onPTTHotkeyChanged
+ self.configStore = configStore
self.onClose = onClose
}
@@ -61,6 +116,13 @@ struct SettingsView: View {
@State private var pttHotkeyDraft: Shell.KeyboardShortcut?
@State private var pttHotkeyError: String?
+ /// Local-first LLM routing (Task 9 / spec §6): derived from the
+ /// `ConfigStore`'s current `PerTurnSnapshot`. Hydrated on `.onAppear`
+ /// from `configStore.perTurn()`; mutated through the `Picker`.
+ /// `nil` until hydrated so the picker doesn't flash a wrong default.
+ @State private var providerMode: ProviderMode = .localFirst
+ @State private var providerModeHydrated: Bool = false
+
enum APIKeyStatus: Equatable {
case unchanged
case validating
@@ -76,6 +138,10 @@ struct SettingsView: View {
apiKeySection
Divider()
+ if configStore != nil {
+ providerModeSection
+ Divider()
+ }
permissionsSection
Divider()
hotkeySection
@@ -99,6 +165,54 @@ struct SettingsView: View {
state.refresh()
hotkeyDraft = state.hotkey
pttHotkeyDraft = state.pttHotkey
+ // Local-first LLM routing (Task 9): hydrate the picker from
+ // the live ConfigStore. The `providerModeHydrated` flag
+ // suppresses the spurious .onChange fire that would otherwise
+ // immediately write back the (just-read) snapshot.
+ if let configStore {
+ Task {
+ let snap = await configStore.perTurn()
+ await MainActor.run {
+ providerMode = ProviderMode.from(
+ provider: snap.provider,
+ escalationEnabled: snap.escalationEnabled
+ )
+ providerModeHydrated = true
+ }
+ }
+ }
+ }
+ }
+
+ // MARK: - Provider mode (Local-first LLM routing, Task 9 / spec §6)
+
+ private var providerModeSection: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ sectionHeader(
+ "Provider",
+ subtitle: "Local-first runs the agent on Ollama and only reaches Anthropic if Ollama can't handle the turn. Anthropic only / Ollama only pin to a single backend."
+ )
+
+ Picker("Mode", selection: $providerMode) {
+ ForEach(ProviderMode.allCases) { mode in
+ Text(mode.label).tag(mode)
+ }
+ }
+ .pickerStyle(.menu)
+ .onChange(of: providerMode) { _, newValue in
+ // Suppress the initial fire from hydration; only persist
+ // user-driven edits.
+ guard providerModeHydrated, let configStore else { return }
+ let pair = newValue.pair
+ Task {
+ let current = await configStore.perTurn()
+ let next = current.with(
+ provider: pair.provider,
+ escalationEnabled: pair.escalationEnabled
+ )
+ await configStore.updatePerTurn(next)
+ }
+ }
}
}
diff --git a/App/Settings/SettingsWindowController.swift b/App/Settings/SettingsWindowController.swift
index 2afdae2..44afb52 100644
--- a/App/Settings/SettingsWindowController.swift
+++ b/App/Settings/SettingsWindowController.swift
@@ -2,6 +2,7 @@ import AppKit
import SwiftUI
import Keychain
import Shell
+import Config
/// Hosts the `SettingsView` in an `NSWindow`. Distinct from the first-run
/// wizard:
@@ -31,7 +32,8 @@ public final class SettingsWindowController {
validator: AnthropicKeyValidator,
onGrantInputMonitoring: @escaping () -> Bool,
onHotkeyChanged: @escaping (Shell.KeyboardShortcut?) -> Void,
- onPTTHotkeyChanged: @escaping (Shell.KeyboardShortcut?) -> Void = { _ in }
+ onPTTHotkeyChanged: @escaping (Shell.KeyboardShortcut?) -> Void = { _ in },
+ configStore: ConfigStore? = nil
) {
// Refresh truth before view creation: TCC grants and Keychain entries
// can change between sessions, same reasoning as the wizard's
@@ -51,6 +53,7 @@ public final class SettingsWindowController {
onGrantInputMonitoring: onGrantInputMonitoring,
onHotkeyChanged: onHotkeyChanged,
onPTTHotkeyChanged: onPTTHotkeyChanged,
+ configStore: configStore,
onClose: { [weak self] in self?.close() }
)
let hosting = NSHostingController(rootView: rootView)
diff --git a/App/Tests/AppTests/ProviderModeTests.swift b/App/Tests/AppTests/ProviderModeTests.swift
new file mode 100644
index 0000000..7a8f4e2
--- /dev/null
+++ b/App/Tests/AppTests/ProviderModeTests.swift
@@ -0,0 +1,51 @@
+import XCTest
+import Config
+@testable import Jarvis
+
+/// Local-first LLM routing (Task 9 / spec §6) — asserts the
+/// `ProviderMode.from(provider:, escalationEnabled:)` mapping that
+/// backs the Settings provider picker:
+///
+/// .ollama + true → .localFirst
+/// .anthropic + * → .anthropicOnly (escalation moot, pinned)
+/// .ollama + false → .ollamaOnly
+final class ProviderModeTests: XCTestCase {
+ func test_provider_mode_mapping_canonical() {
+ XCTAssertEqual(
+ ProviderMode.from(provider: .ollama, escalationEnabled: true),
+ .localFirst
+ )
+ XCTAssertEqual(
+ ProviderMode.from(provider: .anthropic, escalationEnabled: false),
+ .anthropicOnly
+ )
+ XCTAssertEqual(
+ ProviderMode.from(provider: .ollama, escalationEnabled: false),
+ .ollamaOnly
+ )
+ }
+
+ /// `escalationEnabled` is moot when provider is `.anthropic` because
+ /// escalation is an Ollama→Anthropic-only fallback. Both inputs
+ /// collapse to `.anthropicOnly` so the picker shows a consistent
+ /// mode regardless of historical/migrated state.
+ func test_anthropic_with_escalation_true_normalizes_to_anthropic_only() {
+ XCTAssertEqual(
+ ProviderMode.from(provider: .anthropic, escalationEnabled: true),
+ .anthropicOnly
+ )
+ }
+
+ /// The `pair` accessor must round-trip back to the same mode via
+ /// `from(...)`. Guards against an asymmetric edit in either direction.
+ func test_pair_round_trips_through_from() {
+ for mode in ProviderMode.allCases {
+ let pair = mode.pair
+ let roundTripped = ProviderMode.from(
+ provider: pair.provider,
+ escalationEnabled: pair.escalationEnabled
+ )
+ XCTAssertEqual(roundTripped, mode, "round-trip failed for \(mode)")
+ }
+ }
+}
diff --git a/Jarvis.xcodeproj/project.pbxproj b/Jarvis.xcodeproj/project.pbxproj
index 3a417be..4f16aae 100644
--- a/Jarvis.xcodeproj/project.pbxproj
+++ b/Jarvis.xcodeproj/project.pbxproj
@@ -77,6 +77,7 @@
B72852E1EF52C0E6F8170852 /* Memory in Frameworks */ = {isa = PBXBuildFile; productRef = 59F56604A76635EC59D2671B /* Memory */; };
B8F411ADE70402B71887E377 /* VoiceOrchestratorAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 590D5A359A5C3E915D274436 /* VoiceOrchestratorAdapter.swift */; };
BBD8BBC82E0D3C57E0DB4ED0 /* HudStateEnumTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 510B6A716A10A8B7CEEC1D3F /* HudStateEnumTests.swift */; };
+ 95D90BA0F99D46E7A3715F0E /* ProviderModeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B66305B3801453E88D2341F /* ProviderModeTests.swift */; };
BEFC509502C7940A21001F9F /* JarvisHUDPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4424DD9447F84F35372A48FF /* JarvisHUDPanel.swift */; };
C336B40332A148A5A1F2DDC8 /* WebviewBundleLoadTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A16929275D5323F505DF12E9 /* WebviewBundleLoadTests.swift */; };
C3917516BFFF438A4AD5CE80 /* BrandColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BC1E50BF31BB6CCF895FCA3 /* BrandColors.swift */; };
@@ -178,6 +179,7 @@
4BC1E50BF31BB6CCF895FCA3 /* BrandColors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrandColors.swift; sourceTree = ""; };
4DFECDF801143C9F672B5FB7 /* MenuBarIconController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarIconController.swift; sourceTree = ""; };
510B6A716A10A8B7CEEC1D3F /* HudStateEnumTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HudStateEnumTests.swift; sourceTree = ""; };
+ 4B66305B3801453E88D2341F /* ProviderModeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProviderModeTests.swift; sourceTree = ""; };
511D7B2406F485ED0D4B9E0D /* HudStateIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HudStateIntent.swift; sourceTree = ""; };
528FED2A364DE6BD1F2BA20F /* AppBusForwarderSink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppBusForwarderSink.swift; sourceTree = ""; };
54B2C24637464D380D4450AF /* BootHealthProbes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BootHealthProbes.swift; sourceTree = ""; };
@@ -380,6 +382,7 @@
46CB8B7693CEA625D7331C3E /* HudStateCoordinatorBusWiringTests.swift */,
8B7E85856B679CE0788D38DB /* HudStateCoordinatorTests.swift */,
510B6A716A10A8B7CEEC1D3F /* HudStateEnumTests.swift */,
+ 4B66305B3801453E88D2341F /* ProviderModeTests.swift */,
3527AF52C1E81BAC4EF70766 /* JarvisHUDPanelTests.swift */,
6639284829C6DB4F69ED47F7 /* MCPBusGatewayAdapterTests.swift */,
0B5B16BDAC02919861AA4B0B /* MCPRuntimeWiringTests.swift */,
@@ -1298,6 +1301,7 @@
C9F3B34208084769106C4B07 /* HudStateCoordinatorBusWiringTests.swift in Sources */,
8E4590089A7955545E2146D3 /* HudStateCoordinatorTests.swift in Sources */,
BBD8BBC82E0D3C57E0DB4ED0 /* HudStateEnumTests.swift in Sources */,
+ 95D90BA0F99D46E7A3715F0E /* ProviderModeTests.swift in Sources */,
461A5FACC5A3C2025FB0A233 /* JarvisHUDPanelTests.swift in Sources */,
70D09A556654E11B140AFBD4 /* MCPBusGatewayAdapterTests.swift in Sources */,
5AD556F5095852328E72BE36 /* MCPRuntimeWiringTests.swift in Sources */,
diff --git a/packages/AgentCore/Sources/AgentCore/LLMProviderError.swift b/packages/AgentCore/Sources/AgentCore/LLMProviderError.swift
index 5840918..f06958f 100644
--- a/packages/AgentCore/Sources/AgentCore/LLMProviderError.swift
+++ b/packages/AgentCore/Sources/AgentCore/LLMProviderError.swift
@@ -22,4 +22,34 @@ public enum LLMProviderError: Error, Sendable, Equatable {
/// after stream-truncation retry policy gives up. The provider does
/// not emit this directly.
case streamTruncatedFinal
+
+ /// Ollama `/api/chat` emitted a `tool_calls` block whose `arguments`
+ /// failed to decode as JSON. Carries a redacted underlying reason for
+ /// diagnostics. Surfaces `OllamaFailureKind.malformedToolCall` via
+ /// `ollamaFailureKind` so the orchestrator (Task 6) can escalate to
+ /// Anthropic. See spec §2.
+ case malformedToolCall(reason: String)
+
+ /// Ollama stream terminated with zero text deltas AND zero tool-use
+ /// requests — a model-side empty response. Surfaces
+ /// `OllamaFailureKind.emptyResponse` so the orchestrator (Task 6) can
+ /// escalate to Anthropic. See spec §2.
+ case emptyResponse
+
+ /// If this error originated from the local Ollama provider and matches
+ /// one of the closed failure kinds the orchestrator escalates on,
+ /// return that kind. Non-Ollama-specific errors (`.api`, `.decode`,
+ /// `.transport`, `.streamTruncatedFinal`) return `nil`.
+ ///
+ /// `streamTruncated`, `refusal`, `connectionFailure`, and `unknownTool`
+ /// are detected orchestrator-side from `LLMEvent.stopReason`, the
+ /// transport-layer `URLSession` error path, and the tool registry —
+ /// not from this enum.
+ public var ollamaFailureKind: OllamaFailureKind? {
+ switch self {
+ case .malformedToolCall: return .malformedToolCall
+ case .emptyResponse: return .emptyResponse
+ case .api, .decode, .transport, .streamTruncatedFinal: return nil
+ }
+ }
}
diff --git a/packages/AgentCore/Sources/AgentCore/OllamaFailureKind.swift b/packages/AgentCore/Sources/AgentCore/OllamaFailureKind.swift
new file mode 100644
index 0000000..905ba48
--- /dev/null
+++ b/packages/AgentCore/Sources/AgentCore/OllamaFailureKind.swift
@@ -0,0 +1,40 @@
+import Foundation
+import Config
+
+/// Closed enumeration of detectable failure modes for the local Ollama provider
+/// that trigger one-shot escalation to Anthropic Claude Opus 4.7.
+///
+/// See `docs/superpowers/specs/2026-05-14-local-first-llm-routing-design.md` §2.
+///
+/// Adding a new case is a breaking change: orchestrator + tests + HUD badge
+/// reason mapping all need to be updated together.
+public enum OllamaFailureKind: String, Sendable, Codable, Equatable, CaseIterable {
+ /// Mid-stream EOF before `messageStop`. Already wired pre-spec.
+ case streamTruncated
+ /// `tool_calls` payload failed to decode against `ToolUseRequest`.
+ case malformedToolCall
+ /// Model invoked a tool name not in the registry.
+ case unknownTool
+ /// `stopReason: .refusal` event observed.
+ case refusal
+ /// URLSession error before any tokens received.
+ case connectionFailure
+ /// `messageStop` with zero `textDelta` and zero `toolUseRequested`.
+ case emptyResponse
+}
+
+/// Recorded once when reactive escalation fires. Emitted to the HUD via
+/// `BusOutbound.escalated(...)`.
+public struct EscalationDecision: Sendable, Codable, Equatable {
+ public let from: ProviderSelection
+ public let to: ProviderSelection
+ public let reason: OllamaFailureKind
+ public let firedAt: Date
+
+ public init(from: ProviderSelection, to: ProviderSelection, reason: OllamaFailureKind, firedAt: Date) {
+ self.from = from
+ self.to = to
+ self.reason = reason
+ self.firedAt = firedAt
+ }
+}
diff --git a/packages/AgentCore/Sources/AgentOrchestrator/AgentOrchestrator.swift b/packages/AgentCore/Sources/AgentOrchestrator/AgentOrchestrator.swift
index ba8c912..b53e974 100644
--- a/packages/AgentCore/Sources/AgentOrchestrator/AgentOrchestrator.swift
+++ b/packages/AgentCore/Sources/AgentOrchestrator/AgentOrchestrator.swift
@@ -521,6 +521,16 @@ public actor AgentOrchestrator {
// stays on T1 (the user got the response we have). This mirrors the
// AGENT-09 retry budget but is independent of it.
var escalationConsumed = false
+ // Local-first LLM routing (Task 6 / spec §2): one-shot reactive
+ // escalation from Ollama → Anthropic on a `providerError` carrying
+ // an `OllamaFailureKind`. Distinct from the D-02 vision escalation
+ // above (which swaps T1↔T2 mid-turn under the SAME turnId) and from
+ // AGENT-09's stream-truncation retry below (which allocates a fresh
+ // retry turnId). At-most-one fire per logical turn; the second
+ // provider error surfaces a terminal `.error` event. Resets
+ // naturally across turns because this var is declared inside
+ // `runTurnLoop` — each new turn call gets a fresh stack frame.
+ var escalatedThisTurn = false
outer: while true {
// Compute tool_choice based on remaining budget.
@@ -845,6 +855,97 @@ public actor AgentOrchestrator {
}
case .providerError(let err):
+ // Local-first LLM routing (Task 6 / spec §2):
+ // reactive escalation from Ollama → Anthropic when
+ // the provider error carries an `OllamaFailureKind`
+ // AND the current turn is running on Ollama AND
+ // escalation is enabled AND we haven't already
+ // escalated this turn. Otherwise fall through to
+ // the terminal error path below.
+ if let failureKind = err.ollamaFailureKind,
+ currentPerTurn.resolvedProvider == .ollama,
+ currentPerTurn.escalationEnabled,
+ !escalatedThisTurn {
+ escalatedThisTurn = true
+
+ let decision = EscalationDecision(
+ from: .ollama,
+ to: .anthropic,
+ reason: failureKind,
+ firedAt: Date()
+ )
+ await events.send(.escalated(decision))
+
+ // Build the Anthropic provider. If the factory
+ // throws, surface a terminal error — we've
+ // already consumed the escalation budget so
+ // there's no second-chance retry path.
+ let nextProvider: any LLMProvider
+ do {
+ nextProvider = try await providerFactory(.anthropic)
+ } catch {
+ logger.error("providerFactory(.anthropic) failed during escalation", metadata: [
+ "error": "\(error)",
+ ])
+ let redactedErr = Self.redact(err)
+ await events.send(.error(turnId: currentTurnId, error: redactedErr))
+ await replayLog.record(.error(Data(String(describing: redactedErr).utf8)), for: currentTurnId)
+ await replayLog.endTurn(currentTurnId, stopReason: "provider_error")
+ await events.send(.stateChange(.idle))
+ currentTurn = nil
+ return
+ }
+
+ // Close the original (Ollama) turn row with a
+ // distinct stop reason so the replay log
+ // captures the escalation point. Allocate a
+ // fresh turn row for the Anthropic attempt,
+ // mirroring the AGENT-09 streamTruncated-retry
+ // pattern (retryOf set on the new row).
+ await replayLog.endTurn(currentTurnId, stopReason: "ollama_escalation")
+
+ let newTurnId = TurnID.fresh()
+ let originalTurnId = retry.originalTurnId
+ do {
+ try await replayLog.startTurn(
+ turnId: newTurnId,
+ sessionId: sessionId,
+ retryOf: originalTurnId,
+ turnNonce: wrapper.nonce.rawValue,
+ source: source,
+ provider: ProviderSelection.anthropic.rawValue,
+ modelId: modelIDFor(.anthropic).rawValue
+ )
+ } catch {
+ logger.error("escalation startTurn failed", metadata: ["error": "\(error)"])
+ let redactedErr = Self.redact(err)
+ await events.send(.error(turnId: currentTurnId, error: redactedErr))
+ await events.send(.stateChange(.idle))
+ currentTurn = nil
+ return
+ }
+
+ currentTurnId = newTurnId
+ currentProvider = nextProvider
+ currentPerTurn = currentPerTurn.with(provider: .anthropic)
+ // Clean state: restart from initial messages so
+ // Anthropic doesn't inherit Ollama's partial
+ // tool-use round-trips (which may have been
+ // what tripped the malformed-tool-call path).
+ messages = initialMessages
+ toolCallBudget = currentPerTurn.maxToolCallsPerTurn()
+ assistantTextSoFar = ""
+ assistantTextSinceLastFlush = ""
+ if let exec = currentTurn {
+ currentTurn = TurnExecution(
+ id: newTurnId, task: exec.task,
+ startedAt: exec.startedAt, retryOf: originalTurnId
+ )
+ }
+ await events.send(.stateChange(.reconfiguring))
+ continue outer
+ }
+
// HI-01: redact credential-shaped substrings from the
// provider error body BEFORE forwarding to either the
// bus or the replay log. Anthropic's API can echo a
@@ -920,6 +1021,10 @@ public actor AgentOrchestrator {
return .transport(description: Redact.apply(description))
case .streamTruncatedFinal:
return .streamTruncatedFinal
+ case .malformedToolCall(let reason):
+ return .malformedToolCall(reason: Redact.apply(reason))
+ case .emptyResponse:
+ return .emptyResponse
}
}
diff --git a/packages/AgentCore/Sources/AgentOrchestrator/BusForwarder.swift b/packages/AgentCore/Sources/AgentOrchestrator/BusForwarder.swift
index 9022123..cc3289c 100644
--- a/packages/AgentCore/Sources/AgentOrchestrator/BusForwarder.swift
+++ b/packages/AgentCore/Sources/AgentOrchestrator/BusForwarder.swift
@@ -27,6 +27,10 @@ import AgentCore
/// error event in the chat panel — without this branch, mid-stream
/// failures (401 / network drop / streamTruncated) leave the panel
/// silent and the user has no idea their turn died.
+/// - `escalated(decision)` — emits `sendEscalated(decision:)`. Local-first
+/// LLM routing Task 7 — the HUD renders an EscalationBadge keyed by the
+/// `decision.reason`. App-side sink translates the AgentCore type into
+/// the Bus wire type.
/// - All other cases (`stateChange`, `thinkingDelta`, `toolCardUpdate`,
/// `usage`) — ignored. They have dedicated subscribers elsewhere.
public enum BusForwarder {
@@ -93,6 +97,13 @@ public enum BusForwarder {
let reason = "Turn failed: \(String(describing: error))"
await sink.sendSubmitRejected(reason: reason)
+ case let .escalated(decision):
+ // Local-first LLM routing Task 7 — translate to
+ // BusOutbound.escalated downstream. The sink owns the
+ // AgentCore → Bus type translation so this file stays
+ // free of the Bus dependency.
+ await sink.sendEscalated(decision: decision)
+
default:
break
}
@@ -114,4 +125,8 @@ public protocol BusForwarderSink: Sendable {
func sendTurnStarted(id: UUID) async
func sendTurnEnded(id: UUID, terminator: BusForwarder.Terminator) async
func sendSubmitRejected(reason: String) async
+ /// Local-first LLM routing Task 7 — emits `BusOutbound.escalated`. The
+ /// App-side conformer translates `AgentCore.EscalationDecision` into the
+ /// `Bus.EscalationDecision` wire type before flushing.
+ func sendEscalated(decision: EscalationDecision) async
}
diff --git a/packages/AgentCore/Sources/AgentOrchestrator/DevSnapshotEmitter.swift b/packages/AgentCore/Sources/AgentOrchestrator/DevSnapshotEmitter.swift
index 7910c54..27ab5c9 100644
--- a/packages/AgentCore/Sources/AgentOrchestrator/DevSnapshotEmitter.swift
+++ b/packages/AgentCore/Sources/AgentOrchestrator/DevSnapshotEmitter.swift
@@ -157,6 +157,15 @@ public actor DevSnapshotEmitter {
current = current.with(state: .idle, turnId: turnId)
thinkingStartedAt = nil
firstTokenAt = nil
+
+ case .escalated:
+ // Local-first LLM routing (Task 6): the dev overlay's snapshot
+ // shape predates escalation. Treat as a no-op here — Task 8
+ // adds a dedicated HUD badge for the user-visible signal, and
+ // Plan 04-05's DevOverlay can read the `.escalated` event off
+ // its own subscription if needed. No latency/usage fields to
+ // update at this seam.
+ break
}
await output.send(current)
}
diff --git a/packages/AgentCore/Sources/AgentOrchestrator/OrchestratorEvent.swift b/packages/AgentCore/Sources/AgentOrchestrator/OrchestratorEvent.swift
index 1ab4be0..fe175e1 100644
--- a/packages/AgentCore/Sources/AgentOrchestrator/OrchestratorEvent.swift
+++ b/packages/AgentCore/Sources/AgentOrchestrator/OrchestratorEvent.swift
@@ -21,6 +21,14 @@ public enum OrchestratorEvent: Sendable {
case usage(turnId: TurnID, usage: TurnUsage)
case turnEnd(turnId: TurnID, stopReason: StopReason)
case error(turnId: TurnID, error: LLMProviderError)
+
+ /// Local-first LLM routing (Task 6 / spec §2): one-shot reactive
+ /// escalation from Ollama → Anthropic fired by the orchestrator when
+ /// the local provider emits a `providerError` carrying an
+ /// `OllamaFailureKind`. Emitted at most once per logical turn (budget
+ /// resets across turns — see `escalatedThisTurn` in `runTurnLoop`).
+ /// Task 7 wires this to the HUD badge via `BusOutbound.escalated`.
+ case escalated(EscalationDecision)
}
public struct ToolCardUpdate: Sendable {
diff --git a/packages/AgentCore/Sources/OllamaProvider/NDJSONDecoder.swift b/packages/AgentCore/Sources/OllamaProvider/NDJSONDecoder.swift
index 30a71f0..ecc1997 100644
--- a/packages/AgentCore/Sources/OllamaProvider/NDJSONDecoder.swift
+++ b/packages/AgentCore/Sources/OllamaProvider/NDJSONDecoder.swift
@@ -20,6 +20,13 @@ struct NDJSONDecoder {
/// array — used to drive the implicit terminator mapping when
/// `done_reason` is missing or `"stop"` but tool calls were observed.
private(set) var sawToolCalls = false
+ /// Set to `true` once any non-empty text delta has been yielded.
+ /// Combined with `sawAnyToolUseRequested`, drives `emptyResponse`
+ /// detection on the terminator chunk (Task 5 / spec §2).
+ private(set) var sawAnyTextDelta = false
+ /// Set to `true` once at least one `toolUseRequested` event has been
+ /// yielded. Mirrors `sawAnyTextDelta` for the empty-response check.
+ private(set) var sawAnyToolUseRequested = false
/// Final `prompt_eval_count` observed (Ollama only emits it on the
/// terminator chunk in practice; keeping it as an accumulator is safe).
private(set) var promptEvalCount: Int = 0
@@ -83,12 +90,38 @@ struct NDJSONDecoder {
if let args = function["arguments"] {
// Ollama native emits arguments as a JSON object.
if let str = args as? String {
- argsJSON = Data(str.utf8)
+ // String form: validate it parses as JSON before
+ // forwarding — orchestrator escalates on this kind
+ // (Task 5 / spec §2).
+ let strData = Data(str.utf8)
+ do {
+ _ = try JSONSerialization.jsonObject(
+ with: strData,
+ options: [.allowFragments]
+ )
+ argsJSON = strData
+ } catch {
+ yield(.providerError(.malformedToolCall(
+ reason: "tool_calls[].function.arguments string failed to parse as JSON: \(error.localizedDescription)"
+ )))
+ yield(.messageStop)
+ messageStopEmitted = true
+ return
+ }
} else {
- argsJSON = (try? JSONSerialization.data(
- withJSONObject: args,
- options: [.sortedKeys]
- )) ?? Data("{}".utf8)
+ do {
+ argsJSON = try JSONSerialization.data(
+ withJSONObject: args,
+ options: [.sortedKeys]
+ )
+ } catch {
+ yield(.providerError(.malformedToolCall(
+ reason: "tool_calls[].function.arguments object failed to re-encode: \(error.localizedDescription)"
+ )))
+ yield(.messageStop)
+ messageStopEmitted = true
+ return
+ }
}
} else {
argsJSON = Data("{}".utf8)
@@ -100,6 +133,7 @@ struct NDJSONDecoder {
name: name,
argsJSON: argsJSON
)))
+ sawAnyToolUseRequested = true
}
sawToolCalls = true
}
@@ -109,6 +143,7 @@ struct NDJSONDecoder {
let content = message["content"] as? String,
!content.isEmpty {
yield(.textDelta(content))
+ sawAnyTextDelta = true
}
// 3) usage accumulators (Ollama only fills these on the terminator
@@ -136,6 +171,14 @@ struct NDJSONDecoder {
cacheCreationInputTokens: 0,
cacheReadInputTokens: 0
)))
+ // Empty-response detection (Task 5 / spec §2): if the terminator
+ // arrives with zero textDelta AND zero toolUseRequested, surface
+ // `.emptyResponse` so the orchestrator can escalate to Anthropic.
+ // The providerError MUST precede messageStop so consumers that
+ // break on messageStop still observe it.
+ if !sawAnyTextDelta && !sawAnyToolUseRequested {
+ yield(.providerError(.emptyResponse))
+ }
yield(.messageStop)
messageStopEmitted = true
}
diff --git a/packages/AgentCore/Tests/AgentCoreTests/OllamaFailureKindTests.swift b/packages/AgentCore/Tests/AgentCoreTests/OllamaFailureKindTests.swift
new file mode 100644
index 0000000..e6ca850
--- /dev/null
+++ b/packages/AgentCore/Tests/AgentCoreTests/OllamaFailureKindTests.swift
@@ -0,0 +1,26 @@
+import XCTest
+@testable import AgentCore
+
+final class OllamaFailureKindTests: XCTestCase {
+ func test_failure_kind_round_trips_codable() throws {
+ for kind in OllamaFailureKind.allCases {
+ let data = try JSONEncoder().encode(kind)
+ let decoded = try JSONDecoder().decode(OllamaFailureKind.self, from: data)
+ XCTAssertEqual(decoded, kind)
+ }
+ }
+
+ func test_escalation_decision_round_trips_codable() throws {
+ let firedAt = Date(timeIntervalSince1970: 1_700_000_000)
+ let decision = EscalationDecision(
+ from: .ollama,
+ to: .anthropic,
+ reason: .malformedToolCall,
+ firedAt: firedAt
+ )
+ let data = try JSONEncoder().encode(decision)
+ let decoded = try JSONDecoder().decode(EscalationDecision.self, from: data)
+ XCTAssertEqual(decoded, decision)
+ XCTAssertEqual(decoded.firedAt, firedAt)
+ }
+}
diff --git a/packages/AgentCore/Tests/AgentOrchestratorTests/BusForwarderTests.swift b/packages/AgentCore/Tests/AgentOrchestratorTests/BusForwarderTests.swift
index 7a44069..af34fe4 100644
--- a/packages/AgentCore/Tests/AgentOrchestratorTests/BusForwarderTests.swift
+++ b/packages/AgentCore/Tests/AgentOrchestratorTests/BusForwarderTests.swift
@@ -1,5 +1,6 @@
import XCTest
import AgentCore
+import Config
@testable import AgentOrchestrator
/// Coverage for `BusForwarder.drain` — the .bus subscriber's translation
@@ -22,6 +23,7 @@ final class BusForwarderTests: XCTestCase {
private(set) var turnStarts: [UUID] = []
private(set) var turnEnds: [(UUID, BusForwarder.Terminator)] = []
private(set) var rejections: [String] = []
+ private(set) var escalations: [EscalationDecision] = []
func postToken(_ chunk: String) async { tokens.append(chunk) }
func sendTurnStarted(id: UUID) async { turnStarts.append(id) }
@@ -29,14 +31,18 @@ final class BusForwarderTests: XCTestCase {
turnEnds.append((id, terminator))
}
func sendSubmitRejected(reason: String) async { rejections.append(reason) }
+ func sendEscalated(decision: EscalationDecision) async {
+ escalations.append(decision)
+ }
func snapshot() -> (
tokens: [String],
turnStarts: [UUID],
turnEnds: [(UUID, BusForwarder.Terminator)],
- rejections: [String]
+ rejections: [String],
+ escalations: [EscalationDecision]
) {
- (tokens, turnStarts, turnEnds, rejections)
+ (tokens, turnStarts, turnEnds, rejections, escalations)
}
}
@@ -260,6 +266,58 @@ final class BusForwarderTests: XCTestCase {
XCTAssertTrue(snap.rejections.isEmpty)
}
+ // MARK: - Escalation forwarding (BF-ESC1..2)
+
+ /// Local-first LLM routing Task 7 — `.escalated` must surface the
+ /// decision to the sink so the App-side adapter can translate to
+ /// `BusOutbound.escalated` and the HUD can render its badge.
+ func testDrain_escalated_emitsSendEscalatedWithMatchingDecision() async {
+ let sink = RecordingSink()
+ let firedAt = Date(timeIntervalSince1970: 1_700_000_000)
+ let decision = EscalationDecision(
+ from: .ollama,
+ to: .anthropic,
+ reason: .malformedToolCall,
+ firedAt: firedAt
+ )
+ let stream = makeStream([
+ .escalated(decision)
+ ])
+
+ await BusForwarder.drain(events: stream, sink: sink)
+
+ let snap = await sink.snapshot()
+ XCTAssertEqual(snap.escalations.count, 1)
+ XCTAssertEqual(snap.escalations.first, decision)
+ XCTAssertTrue(snap.tokens.isEmpty)
+ XCTAssertTrue(snap.turnStarts.isEmpty)
+ XCTAssertTrue(snap.turnEnds.isEmpty)
+ XCTAssertTrue(snap.rejections.isEmpty)
+ }
+
+ /// `.escalated` does NOT terminate the turn — the orchestrator continues
+ /// streaming on the next provider. The forwarder must not synthesize a
+ /// turnStarted/turnEnded pair on top of the escalation event.
+ func testDrain_escalated_doesNotEmitTurnLifecycleEvents() async {
+ let sink = RecordingSink()
+ let decision = EscalationDecision(
+ from: .ollama,
+ to: .anthropic,
+ reason: .connectionFailure,
+ firedAt: Date(timeIntervalSince1970: 1_700_000_001)
+ )
+ let stream = makeStream([
+ .escalated(decision)
+ ])
+
+ await BusForwarder.drain(events: stream, sink: sink)
+
+ let snap = await sink.snapshot()
+ XCTAssertEqual(snap.escalations.count, 1)
+ XCTAssertTrue(snap.turnStarts.isEmpty)
+ XCTAssertTrue(snap.turnEnds.isEmpty)
+ }
+
// MARK: - Malformed turn id guard (BF-G1)
func testDrain_tokenDelta_withNonUUIDTurnId_doesNotCrashOrEmitStart() async {
diff --git a/packages/AgentCore/Tests/AgentOrchestratorTests/LocalFirstEscalationTests.swift b/packages/AgentCore/Tests/AgentOrchestratorTests/LocalFirstEscalationTests.swift
new file mode 100644
index 0000000..a4c1e4a
--- /dev/null
+++ b/packages/AgentCore/Tests/AgentOrchestratorTests/LocalFirstEscalationTests.swift
@@ -0,0 +1,235 @@
+import XCTest
+import AgentCore
+import Config
+import Replay
+@testable import AgentOrchestrator
+
+/// Task 6 (local-first LLM routing spec §2, §3): reactive escalation from
+/// Ollama → Anthropic when the local provider emits a
+/// `providerError(ollamaFailureKind: ...)`. Budget: at most ONE escalation
+/// per turn; second failure surfaces a terminal error. Disabling
+/// `escalationEnabled` short-circuits the retry entirely.
+@MainActor
+final class LocalFirstEscalationTests: XCTestCase {
+ var tempHome: TempReplayHome!
+
+ override func setUp() async throws {
+ try await super.setUp()
+ tempHome = TempReplayHome.make()
+ }
+
+ override func tearDown() async throws {
+ tempHome?.cleanup()
+ try await super.tearDown()
+ }
+
+ // MARK: - Helpers
+
+ /// Build a `PerTurnSnapshot` honouring `escalationEnabled`. The fixture in
+ /// `Helpers.swift` doesn't expose the flag because pre-Task-3 snapshots
+ /// always defaulted to `true`; this test file forces the flag explicitly.
+ private func snapshot(
+ provider: ProviderSelection,
+ escalationEnabled: Bool
+ ) -> PerTurnSnapshot {
+ let providerString = provider.rawValue
+ let json = """
+ {"schemaVersion":1,"provider":"\(providerString)","escalationEnabled":\(escalationEnabled),"tts":{"tier":"tier1"},"stt":{"whisperKitFallback":false},"featureFlags":{}}
+ """.data(using: .utf8)!
+ return try! JSONDecoder().decode(PerTurnSnapshot.self, from: json)
+ }
+
+ private func buildOrchestrator(
+ provider mock: MockLLMProvider,
+ configStore cs: ConfigStore
+ ) async throws -> (AgentOrchestrator, ReplayLog) {
+ let replay = try ReplayLog(databaseURL: tempHome.dbURL)
+ let session = try await replay.beginSession(appVersion: "test", buildSHA: "abc")
+ let orch = AgentOrchestrator(
+ configStore: cs,
+ providerFactory: { _ in mock },
+ toolDispatcher: StubToolDispatcher(),
+ replayLog: replay,
+ sessionId: session,
+ systemPrompt: "you are jarvis"
+ )
+ return (orch, replay)
+ }
+
+ private func collectUntil(
+ orch: AgentOrchestrator,
+ timeout: TimeInterval = 5.0,
+ predicate: @escaping (OrchestratorEvent) -> Bool
+ ) async -> [OrchestratorEvent] {
+ var collected: [OrchestratorEvent] = []
+ let deadline = Date().addingTimeInterval(timeout)
+ for await event in orch.events {
+ collected.append(event)
+ if predicate(event) { return collected }
+ if Date() > deadline { return collected }
+ }
+ return collected
+ }
+
+ // MARK: - A. Reactive fallback fires once on malformedToolCall
+
+ /// Provider running on Ollama emits providerError(.malformedToolCall) on
+ /// the first call; orchestrator escalates to Anthropic; second call
+ /// returns a clean happy-path stream. Exactly one `.escalated` event;
+ /// the turn ends cleanly.
+ func test_A_reactiveFallbackFiresOnceOnMalformedToolCall() async throws {
+ let mock = MockLLMProvider(scripts: [
+ // First attempt (Ollama): malformed tool call surfaces as
+ // providerError mid-stream. The Ollama provider always emits
+ // .messageStart before any failure detection (see Task 5).
+ .init(events: [
+ .messageStart(LLMMessageStart(messageId: "m1", model: "qwen", usagePrefix: nil)),
+ .providerError(.malformedToolCall(reason: "tool args were not valid JSON")),
+ ]),
+ // Second attempt (Anthropic after escalation): completes cleanly.
+ .init(events: [
+ .messageStart(LLMMessageStart(messageId: "m2", model: "opus", usagePrefix: nil)),
+ .textDelta("Hello."),
+ .stopReason(.endTurn),
+ .messageStop,
+ ]),
+ ])
+ let cs = ConfigStore(
+ launch: makeLaunchSnapshot(),
+ initial: snapshot(provider: .ollama, escalationEnabled: true)
+ )
+ let (orch, _) = try await buildOrchestrator(provider: mock, configStore: cs)
+
+ _ = await orch.submit(.text("ping"))
+ let events = await collectUntil(orch: orch) { ev in
+ if case .stateChange(.idle) = ev { return true }; return false
+ }
+
+ // Exactly one escalation event observed with reason == .malformedToolCall.
+ let escalations: [EscalationDecision] = events.compactMap { ev in
+ if case .escalated(let d) = ev { return d }
+ return nil
+ }
+ XCTAssertEqual(escalations.count, 1, "expected exactly one escalation; got \(escalations.count) in \(events)")
+ XCTAssertEqual(escalations.first?.reason, .malformedToolCall)
+ XCTAssertEqual(escalations.first?.from, .ollama)
+ XCTAssertEqual(escalations.first?.to, .anthropic)
+
+ // Turn ended cleanly (no terminal error).
+ let sawError = events.contains { ev in
+ if case .error = ev { return true }; return false
+ }
+ XCTAssertFalse(sawError, "turn should end cleanly post-escalation; got \(events)")
+ XCTAssertTrue(events.contains(where: { ev in
+ if case .turnEnd(_, let r) = ev, r == .endTurn { return true }; return false
+ }), "expected terminal .turnEnd(.endTurn) on Anthropic")
+
+ // Two provider calls: Ollama (failed) + Anthropic (succeeded).
+ let calls = await mock.getRecordedCalls()
+ XCTAssertEqual(calls.count, 2, "expected one escalation → 2 provider calls; got \(calls.count)")
+ }
+
+ // MARK: - B. Escalation budget honoured (both providers fail)
+
+ /// Ollama emits providerError(.malformedToolCall); Anthropic (post-
+ /// escalation) ALSO emits a providerError. Orchestrator does NOT
+ /// escalate a second time — surfaces a terminal error. Exactly one
+ /// `.escalated` event.
+ func test_B_escalationBudgetHonoredBothProvidersFail() async throws {
+ let mock = MockLLMProvider(scripts: [
+ // First attempt (Ollama): malformedToolCall.
+ .init(events: [
+ .messageStart(LLMMessageStart(messageId: "m1", model: "qwen", usagePrefix: nil)),
+ .providerError(.malformedToolCall(reason: "bad tool args")),
+ ]),
+ // Second attempt (Anthropic post-escalation): emptyResponse —
+ // doesn't matter that it's an Ollama-flavoured kind; the
+ // orchestrator's budget check is purely on escalatedThisTurn,
+ // not on the kind. We just need a providerError outcome.
+ .init(events: [
+ .messageStart(LLMMessageStart(messageId: "m2", model: "opus", usagePrefix: nil)),
+ .providerError(.api(statusCode: 500, body: "upstream borked")),
+ ]),
+ // Third attempt sentinel — must NOT be invoked.
+ .init(events: [
+ .messageStart(LLMMessageStart(messageId: "should-not-fire", model: "x", usagePrefix: nil)),
+ .stopReason(.endTurn),
+ .messageStop,
+ ]),
+ ])
+ let cs = ConfigStore(
+ launch: makeLaunchSnapshot(),
+ initial: snapshot(provider: .ollama, escalationEnabled: true)
+ )
+ let (orch, _) = try await buildOrchestrator(provider: mock, configStore: cs)
+
+ _ = await orch.submit(.text("ping"))
+ let events = await collectUntil(orch: orch) { ev in
+ if case .stateChange(.idle) = ev { return true }; return false
+ }
+
+ // Exactly one escalation event.
+ let escalations: [EscalationDecision] = events.compactMap { ev in
+ if case .escalated(let d) = ev { return d }
+ return nil
+ }
+ XCTAssertEqual(escalations.count, 1, "expected exactly one escalation despite second failure")
+
+ // Terminal .error event present.
+ let sawError = events.contains { ev in
+ if case .error = ev { return true }; return false
+ }
+ XCTAssertTrue(sawError, "second failure must surface a terminal error; got \(events)")
+
+ // Exactly two provider calls: no third attempt.
+ let calls = await mock.getRecordedCalls()
+ XCTAssertEqual(calls.count, 2, "must NOT escalate twice; got \(calls.count) calls")
+ }
+
+ // MARK: - C. Escalation disabled surfaces error directly
+
+ /// `escalationEnabled == false`, provider is .ollama, providerError
+ /// fires. Orchestrator does NOT escalate; surfaces a terminal error and
+ /// calls the provider only ONCE.
+ func test_C_escalationDisabledSurfacesErrorDirectly() async throws {
+ let mock = MockLLMProvider(scripts: [
+ .init(events: [
+ .messageStart(LLMMessageStart(messageId: "m1", model: "qwen", usagePrefix: nil)),
+ .providerError(.malformedToolCall(reason: "bad tool args")),
+ ]),
+ // Sentinel — must NOT be invoked.
+ .init(events: [
+ .messageStart(LLMMessageStart(messageId: "should-not-fire", model: "x", usagePrefix: nil)),
+ .stopReason(.endTurn),
+ .messageStop,
+ ]),
+ ])
+ let cs = ConfigStore(
+ launch: makeLaunchSnapshot(),
+ initial: snapshot(provider: .ollama, escalationEnabled: false)
+ )
+ let (orch, _) = try await buildOrchestrator(provider: mock, configStore: cs)
+
+ _ = await orch.submit(.text("ping"))
+ let events = await collectUntil(orch: orch) { ev in
+ if case .stateChange(.idle) = ev { return true }; return false
+ }
+
+ // ZERO escalation events.
+ let escalations: [EscalationDecision] = events.compactMap { ev in
+ if case .escalated(let d) = ev { return d }
+ return nil
+ }
+ XCTAssertEqual(escalations.count, 0, "escalationEnabled=false must NOT emit .escalated; got \(escalations)")
+
+ // Terminal .error event surfaced.
+ let sawError = events.contains { ev in
+ if case .error = ev { return true }; return false
+ }
+ XCTAssertTrue(sawError, "disabled escalation must surface a terminal error; got \(events)")
+
+ // Exactly one provider call.
+ let calls = await mock.getRecordedCalls()
+ XCTAssertEqual(calls.count, 1, "disabled escalation must NOT retry; got \(calls.count) calls")
+ }
+}
diff --git a/packages/AgentCore/Tests/AgentOrchestratorTests/OrchestratorEventBroadcasterTests.swift b/packages/AgentCore/Tests/AgentOrchestratorTests/OrchestratorEventBroadcasterTests.swift
index fe80a43..f62a1c8 100644
--- a/packages/AgentCore/Tests/AgentOrchestratorTests/OrchestratorEventBroadcasterTests.swift
+++ b/packages/AgentCore/Tests/AgentOrchestratorTests/OrchestratorEventBroadcasterTests.swift
@@ -327,6 +327,7 @@ final class OrchestratorEventBroadcasterTests: XCTestCase {
case .usage: return "usage"
case .turnEnd: return "turnEnd"
case .error: return "error"
+ case .escalated: return "escalated"
}
}
}
diff --git a/packages/AgentCore/Tests/AgentOrchestratorTests/TextInputEndToEndTests.swift b/packages/AgentCore/Tests/AgentOrchestratorTests/TextInputEndToEndTests.swift
index 119e2df..59ec844 100644
--- a/packages/AgentCore/Tests/AgentOrchestratorTests/TextInputEndToEndTests.swift
+++ b/packages/AgentCore/Tests/AgentOrchestratorTests/TextInputEndToEndTests.swift
@@ -211,6 +211,7 @@ final class TextInputEndToEndTests: XCTestCase {
case .usage: return "usage"
case .turnEnd: return "turnEnd"
case .error: return "error"
+ case .escalated: return "escalated"
}
}
}
diff --git a/packages/AgentCore/Tests/OllamaProviderTests/NDJSONDecoderTests.swift b/packages/AgentCore/Tests/OllamaProviderTests/NDJSONDecoderTests.swift
index 32c84a0..2c01e08 100644
--- a/packages/AgentCore/Tests/OllamaProviderTests/NDJSONDecoderTests.swift
+++ b/packages/AgentCore/Tests/OllamaProviderTests/NDJSONDecoderTests.swift
@@ -181,4 +181,80 @@ final class NDJSONDecoderTests: XCTestCase {
})
XCTAssertTrue(events.contains { if case .messageStop = $0 { return true } else { return false } })
}
+
+ // MARK: - N8: malformedToolCall failure kind (Task 5 / spec §2)
+
+ /// `tool_calls[].function.arguments` is present as a string but is not
+ /// decodable as JSON — the decoder MUST emit a `providerError` carrying
+ /// `OllamaFailureKind.malformedToolCall` so the orchestrator (Task 6)
+ /// can escalate to Anthropic.
+ func testMalformedToolCallEmitsProviderError() {
+ let events = run([
+ #"{"model":"qwen2.5-coder:32b","message":{"role":"assistant","content":"","tool_calls":[{"function":{"name":"get_time","arguments":"this is not json"}}]},"done":false}"#,
+ #"{"message":{"content":""},"done":true}"#,
+ ])
+ XCTAssertTrue(events.contains { event in
+ if case .providerError(let err) = event,
+ err.ollamaFailureKind == .malformedToolCall { return true }
+ return false
+ }, "Expected providerError carrying ollamaFailureKind == .malformedToolCall, got: \(events)")
+ }
+
+ // MARK: - N9: emptyResponse failure kind (Task 5 / spec §2)
+
+ /// Stream that terminates with zero textDelta and zero toolUseRequested
+ /// must emit `providerError(.emptyResponse)` BEFORE `messageStop`. This
+ /// drives the orchestrator's reactive escalation to Anthropic.
+ func testEmptyResponseEmitsProviderError() {
+ let events = run([
+ #"{"model":"qwen2.5-coder:32b","message":{"role":"assistant","content":""},"done":true,"done_reason":"stop"}"#,
+ ])
+ XCTAssertTrue(events.contains { event in
+ if case .providerError(let err) = event,
+ err.ollamaFailureKind == .emptyResponse { return true }
+ return false
+ }, "Expected providerError carrying ollamaFailureKind == .emptyResponse, got: \(events)")
+ // emptyResponse must precede messageStop.
+ let errorIdx = events.firstIndex { event in
+ if case .providerError(let err) = event,
+ err.ollamaFailureKind == .emptyResponse { return true }
+ return false
+ }
+ let stopIdx = events.firstIndex { event in
+ if case .messageStop = event { return true } else { return false }
+ }
+ XCTAssertNotNil(errorIdx)
+ XCTAssertNotNil(stopIdx)
+ if let e = errorIdx, let s = stopIdx {
+ XCTAssertLessThan(e, s, "emptyResponse providerError must precede messageStop")
+ }
+ }
+
+ /// Negative case: a stream with at least one textDelta must NOT emit
+ /// `.emptyResponse` on terminator.
+ func testTextDeltaSuppressesEmptyResponse() {
+ let events = run([
+ #"{"message":{"role":"assistant","content":"hi"},"done":false}"#,
+ #"{"message":{"content":""},"done":true,"done_reason":"stop"}"#,
+ ])
+ XCTAssertFalse(events.contains { event in
+ if case .providerError(let err) = event,
+ err.ollamaFailureKind == .emptyResponse { return true }
+ return false
+ }, "non-empty stream must not emit emptyResponse, got: \(events)")
+ }
+
+ /// Negative case: a stream with at least one toolUseRequested must NOT
+ /// emit `.emptyResponse` on terminator.
+ func testToolUseSuppressesEmptyResponse() {
+ let events = run([
+ #"{"message":{"content":"","tool_calls":[{"function":{"name":"get_time","arguments":{}}}]},"done":false}"#,
+ #"{"message":{"content":""},"done":true,"done_reason":"tool_calls"}"#,
+ ])
+ XCTAssertFalse(events.contains { event in
+ if case .providerError(let err) = event,
+ err.ollamaFailureKind == .emptyResponse { return true }
+ return false
+ }, "tool-use stream must not emit emptyResponse, got: \(events)")
+ }
}
diff --git a/packages/Bus/Sources/Bus/BusOutbound.swift b/packages/Bus/Sources/Bus/BusOutbound.swift
index 883e076..9c8a222 100644
--- a/packages/Bus/Sources/Bus/BusOutbound.swift
+++ b/packages/Bus/Sources/Bus/BusOutbound.swift
@@ -29,6 +29,59 @@ public enum BusOutbound: Equatable, Sendable {
/// — byte-identical to the voice-path HUD banner so users see the same
/// wording regardless of input source.
case submitRejected(reason: String)
+ /// Local-first LLM routing (spec §4 / Task 7) — emitted exactly once per
+ /// logical turn when the orchestrator reactively escalates from Ollama
+ /// to Anthropic Claude on a detected failure. The HUD renders an
+ /// EscalationBadge keyed by `EscalationDecision.reason`.
+ case escalated(EscalationDecision)
+}
+
+/// Local mirror of `Config.ProviderSelection` used by `EscalationDecision`.
+///
+/// Bus deliberately does NOT depend on the Config or AgentCore packages —
+/// adding those edges would pull their transitive deps into the lightweight
+/// bridge layer. This mirrors `Config.ProviderSelection` one-for-one; the
+/// App-side `AppBusForwarderSink` translates between the two when emitting.
+public enum BusProviderSelection: String, Codable, Sendable, CaseIterable, Equatable {
+ case anthropic
+ case ollama
+}
+
+/// Local mirror of `AgentCore.OllamaFailureKind` used by `EscalationDecision`.
+///
+/// Same dependency-isolation rationale as `BusProviderSelection`. Adding a
+/// new case is a breaking change for the bus wire format; mirror it on the
+/// TS side at the same time.
+public enum BusOllamaFailureKind: String, Codable, Sendable, CaseIterable, Equatable {
+ case streamTruncated
+ case malformedToolCall
+ case unknownTool
+ case refusal
+ case connectionFailure
+ case emptyResponse
+}
+
+/// Local mirror of `AgentCore.EscalationDecision` carried by
+/// `BusOutbound.escalated(_:)`. Same dependency-isolation rationale as
+/// `TurnRow`. App-side `AppBusForwarderSink` translates between the two
+/// when emitting.
+public struct EscalationDecision: Codable, Equatable, Sendable {
+ public let from: BusProviderSelection
+ public let to: BusProviderSelection
+ public let reason: BusOllamaFailureKind
+ public let firedAt: Date
+
+ public init(
+ from: BusProviderSelection,
+ to: BusProviderSelection,
+ reason: BusOllamaFailureKind,
+ firedAt: Date
+ ) {
+ self.from = from
+ self.to = to
+ self.reason = reason
+ self.firedAt = firedAt
+ }
}
/// Local mirror of Memory.TurnRow used by `BusOutbound.sessionHistory`.
@@ -79,6 +132,7 @@ extension BusOutbound: Codable {
case turnEnded
case sessionHistory
case submitRejected
+ case escalated
}
/// All `CodingKeys` across every case. Swift's keyed container does not
@@ -98,6 +152,7 @@ extension BusOutbound: Codable {
case terminator
case turns
case reason
+ case decision
}
public init(from decoder: Decoder) throws {
@@ -139,6 +194,9 @@ extension BusOutbound: Codable {
case .submitRejected:
let reason = try container.decode(String.self, forKey: .reason)
self = .submitRejected(reason: reason)
+ case .escalated:
+ let decision = try container.decode(EscalationDecision.self, forKey: .decision)
+ self = .escalated(decision)
}
// NO default branch — adding a Discriminator case without also adding
// a matching switch arm here is a compile error. That is the entire
@@ -196,6 +254,9 @@ extension BusOutbound: Codable {
case .submitRejected(let reason):
try container.encode(Discriminator.submitRejected, forKey: .type)
try container.encode(reason, forKey: .reason)
+ case .escalated(let decision):
+ try container.encode(Discriminator.escalated, forKey: .type)
+ try container.encode(decision, forKey: .decision)
}
// Exhaustive at encode site — adding a case without encoding it is a
// compile error, mirroring the init(from:) drift preventer.
diff --git a/packages/Bus/Sources/Bus/Protocol.swift b/packages/Bus/Sources/Bus/Protocol.swift
index 53e70aa..cc1751b 100644
--- a/packages/Bus/Sources/Bus/Protocol.swift
+++ b/packages/Bus/Sources/Bus/Protocol.swift
@@ -25,7 +25,11 @@ import Foundation
/// `chatSubmit(text:)` + `chatCancelAndSubmit(text:)` (chat-panel submit +
/// barge-in) and additive BusOutbound case `submitRejected(reason:)` (D-10
/// text-path rejection toast, byte-identical body to voice-path HUD banner).
-public let BUS_PROTOCOL_VERSION: String = "2.3.0"
+///
+/// Local-first LLM routing Task 7 — bumped 2.3.0 → 2.4.0 for additive
+/// BusOutbound case `escalated(EscalationDecision)`. MINOR per HUD-05
+/// (additive; older webviews can still decode the outbound shape).
+public let BUS_PROTOCOL_VERSION: String = "2.4.0"
/// UI states the HUD can occupy. Encoded as its `rawValue` inside
/// `BusOutbound.hudState(_:)` — see `BusOutbound.encode(to:)`.
diff --git a/packages/Bus/Sources/Bus/Resources/Injection.js b/packages/Bus/Sources/Bus/Resources/Injection.js
index 90d85b3..724c843 100644
--- a/packages/Bus/Sources/Bus/Resources/Injection.js
+++ b/packages/Bus/Sources/Bus/Resources/Injection.js
@@ -17,7 +17,7 @@
"use strict";
if (window.jarvisBus) { return; }
window.jarvisBus = {
- protocolVersion: "2.3.0",
+ protocolVersion: "2.4.0",
_handler: null,
// Queue (not a single slot) of messages received before the bundle
// registered its handler via onOutbound. With a single slot, an early
diff --git a/packages/Bus/Tests/BusTests/CodableRoundTripTests.swift b/packages/Bus/Tests/BusTests/CodableRoundTripTests.swift
index 6a6fc3f..0adbdd1 100644
--- a/packages/Bus/Tests/BusTests/CodableRoundTripTests.swift
+++ b/packages/Bus/Tests/BusTests/CodableRoundTripTests.swift
@@ -234,6 +234,26 @@ final class CodableRoundTripTests: XCTestCase {
)
}
+ /// Local-first LLM routing Task 7 — additive `escalated(decision)` case
+ /// carrying an `EscalationDecision` for the HUD escalation badge.
+ /// ISO-8601 date encoding is exercised here; `BusCoder.makeEncoder/Decoder`
+ /// pin the strategy at `.iso8601`.
+ func test_roundTrip_escalated() throws {
+ // `firedAt` chosen to round-trip cleanly through the `.iso8601`
+ // strategy with no fractional seconds (matches the fixture verbatim).
+ let firedAt = ISO8601DateFormatter().date(from: "2026-05-14T12:00:00Z")!
+ let decision = EscalationDecision(
+ from: .ollama,
+ to: .anthropic,
+ reason: .malformedToolCall,
+ firedAt: firedAt
+ )
+ try assertOutboundRoundTrips(
+ fixture: "escalated",
+ expect: .escalated(decision)
+ )
+ }
+
// MARK: - Shape invariants (these prove hand-written Codable is load-bearing)
/// If SE-0295 synthesis leaked, we'd see `{"hudState":{"state":"idle"}}`.
diff --git a/packages/Bus/Tests/BusTests/Fixtures/escalated.json b/packages/Bus/Tests/BusTests/Fixtures/escalated.json
new file mode 100644
index 0000000..7ab6164
--- /dev/null
+++ b/packages/Bus/Tests/BusTests/Fixtures/escalated.json
@@ -0,0 +1 @@
+{"type":"escalated","decision":{"from":"ollama","to":"anthropic","reason":"malformedToolCall","firedAt":"2026-05-14T12:00:00Z"}}
\ No newline at end of file
diff --git a/packages/Config/Sources/Config/PerTurnSnapshot.swift b/packages/Config/Sources/Config/PerTurnSnapshot.swift
index 1d9c8eb..8fa0f36 100644
--- a/packages/Config/Sources/Config/PerTurnSnapshot.swift
+++ b/packages/Config/Sources/Config/PerTurnSnapshot.swift
@@ -3,8 +3,74 @@ import Foundation
public struct PerTurnSnapshot: Sendable, Codable, Equatable {
public let schemaVersion: Int
public let provider: ProviderSelection
+ public let escalationEnabled: Bool
public let tts: TTSConfig
public let stt: STTConfig
public let featureFlags: FeatureFlags
// DO NOT add: ollama host, applescript policy, toolBlocklist, confirmationPolicy (all Launch-pinned per SEC-05).
+
+ public init(
+ schemaVersion: Int,
+ provider: ProviderSelection,
+ escalationEnabled: Bool = true,
+ tts: TTSConfig,
+ stt: STTConfig,
+ featureFlags: FeatureFlags
+ ) {
+ self.schemaVersion = schemaVersion
+ self.provider = provider
+ self.escalationEnabled = escalationEnabled
+ self.tts = tts
+ self.stt = stt
+ self.featureFlags = featureFlags
+ }
+
+ private enum CodingKeys: String, CodingKey {
+ case schemaVersion, provider, escalationEnabled, tts, stt, featureFlags
+ }
+
+ public init(from decoder: Decoder) throws {
+ let c = try decoder.container(keyedBy: CodingKeys.self)
+ self.schemaVersion = try c.decode(Int.self, forKey: .schemaVersion)
+ self.provider = try c.decode(ProviderSelection.self, forKey: .provider)
+ self.escalationEnabled = try c.decodeIfPresent(Bool.self, forKey: .escalationEnabled) ?? true
+ self.tts = try c.decode(TTSConfig.self, forKey: .tts)
+ self.stt = try c.decode(STTConfig.self, forKey: .stt)
+ self.featureFlags = try c.decode(FeatureFlags.self, forKey: .featureFlags)
+ }
+
+ /// Local-first LLM routing (Task 6 / spec §2): convenience copy with a
+ /// different provider. Used by `AgentOrchestrator` on reactive
+ /// escalation to swap `.ollama` → `.anthropic` for the same turn
+ /// without mutating the underlying `ConfigStore` (the escalation is a
+ /// per-turn decision, not a config update).
+ public func with(provider: ProviderSelection) -> PerTurnSnapshot {
+ PerTurnSnapshot(
+ schemaVersion: schemaVersion,
+ provider: provider,
+ escalationEnabled: escalationEnabled,
+ tts: tts,
+ stt: stt,
+ featureFlags: featureFlags
+ )
+ }
+
+ /// Local-first LLM routing (Task 9 / spec §6): convenience copy that
+ /// mutates the `{provider, escalationEnabled}` pair atomically. Used
+ /// by the Settings provider-mode picker so a single toggle persists
+ /// both fields through `ConfigStore.updatePerTurn(_:)`. Other fields
+ /// are preserved verbatim.
+ public func with(
+ provider: ProviderSelection,
+ escalationEnabled: Bool
+ ) -> PerTurnSnapshot {
+ PerTurnSnapshot(
+ schemaVersion: schemaVersion,
+ provider: provider,
+ escalationEnabled: escalationEnabled,
+ tts: tts,
+ stt: stt,
+ featureFlags: featureFlags
+ )
+ }
}
diff --git a/packages/Config/Sources/Config/Resources/default-config.json b/packages/Config/Sources/Config/Resources/default-config.json
index 283442d..a5704e9 100644
--- a/packages/Config/Sources/Config/Resources/default-config.json
+++ b/packages/Config/Sources/Config/Resources/default-config.json
@@ -6,7 +6,8 @@
"applescript": {
"confirmationRequired": true
},
- "provider": "anthropic",
+ "provider": "ollama",
+ "escalationEnabled": true,
"tts": { "tier": "tier1" },
"stt": { "whisperKitFallback": false },
"featureFlags": {
diff --git a/packages/Config/Tests/ConfigTests/ConfigLoaderTests.swift b/packages/Config/Tests/ConfigTests/ConfigLoaderTests.swift
index 10993ef..5e38c3b 100644
--- a/packages/Config/Tests/ConfigTests/ConfigLoaderTests.swift
+++ b/packages/Config/Tests/ConfigTests/ConfigLoaderTests.swift
@@ -10,7 +10,7 @@ final class ConfigLoaderTests: XCTestCase {
XCTAssertEqual(launch.schemaVersion, 1)
XCTAssertEqual(perTurn.schemaVersion, 1)
XCTAssertEqual(launch.ollama.baseURL.host, "127.0.0.1")
- XCTAssertEqual(perTurn.provider, .anthropic)
+ XCTAssertEqual(perTurn.provider, .ollama)
}
func test_malformedConfigThrowsMalformed() throws {
diff --git a/packages/Config/Tests/ConfigTests/PerTurnSnapshotTests.swift b/packages/Config/Tests/ConfigTests/PerTurnSnapshotTests.swift
index b234205..e86078f 100644
--- a/packages/Config/Tests/ConfigTests/PerTurnSnapshotTests.swift
+++ b/packages/Config/Tests/ConfigTests/PerTurnSnapshotTests.swift
@@ -38,6 +38,72 @@ final class PerTurnSnapshotTests: XCTestCase {
}
}
+final class PerTurnSnapshotEscalationTests: XCTestCase {
+ func test_escalation_enabled_round_trips() throws {
+ let json = """
+ {
+ "schemaVersion": 1,
+ "provider": "ollama",
+ "escalationEnabled": true,
+ "tts": { "tier": "tier1" },
+ "stt": { "whisperKitFallback": false },
+ "featureFlags": { "orpheusTTSEnabled": false, "whisperKitSTTEnabled": false }
+ }
+ """.data(using: .utf8)!
+
+ let snapshot = try JSONDecoder().decode(PerTurnSnapshot.self, from: json)
+ XCTAssertEqual(snapshot.provider, .ollama)
+ XCTAssertTrue(snapshot.escalationEnabled)
+ }
+
+ func test_escalation_enabled_defaults_to_true_when_absent() throws {
+ let json = """
+ {
+ "schemaVersion": 1,
+ "provider": "ollama",
+ "tts": { "tier": "tier1" },
+ "stt": { "whisperKitFallback": false },
+ "featureFlags": { "orpheusTTSEnabled": false, "whisperKitSTTEnabled": false }
+ }
+ """.data(using: .utf8)!
+
+ let snapshot = try JSONDecoder().decode(PerTurnSnapshot.self, from: json)
+ XCTAssertTrue(snapshot.escalationEnabled)
+ }
+
+ func test_default_config_resource_is_local_first() throws {
+ let url = ConfigLoader.bundledDefaultConfigURL()!
+ let data = try Data(contentsOf: url)
+ let snapshot = try JSONDecoder().decode(PerTurnSnapshot.self, from: data)
+ XCTAssertEqual(snapshot.provider, .ollama,
+ "default-config.json must specify ollama as the default chat-turn provider (local-first)")
+ XCTAssertTrue(snapshot.escalationEnabled,
+ "default-config.json must enable escalation for new users (local-first)")
+ }
+
+ func test_escalation_enabled_survives_encode_decode_round_trip_when_false() throws {
+ // Catches a future regression where someone adds a custom encode(to:)
+ // that drops escalationEnabled — encoder must continue emitting the
+ // field so decoder reads the user's explicit `false` rather than
+ // defaulting back to `true`.
+ let original = PerTurnSnapshot(
+ schemaVersion: 1,
+ provider: .ollama,
+ escalationEnabled: false,
+ tts: TTSConfig(tier: "tier1"),
+ stt: STTConfig(whisperKitFallback: false),
+ featureFlags: FeatureFlags(["orpheusTTSEnabled": false, "whisperKitSTTEnabled": false])
+ )
+
+ let encoded = try JSONEncoder().encode(original)
+ let decoded = try JSONDecoder().decode(PerTurnSnapshot.self, from: encoded)
+
+ XCTAssertFalse(decoded.escalationEnabled,
+ "Encoder must emit escalationEnabled so round-trip preserves the user's explicit false")
+ XCTAssertEqual(decoded, original)
+ }
+}
+
enum ConfigTestFixtures {
static func launch() throws -> LaunchSnapshot {
let json = """
diff --git a/webview/packages/bus/fixtures/escalated.json b/webview/packages/bus/fixtures/escalated.json
new file mode 100644
index 0000000..7ab6164
--- /dev/null
+++ b/webview/packages/bus/fixtures/escalated.json
@@ -0,0 +1 @@
+{"type":"escalated","decision":{"from":"ollama","to":"anthropic","reason":"malformedToolCall","firedAt":"2026-05-14T12:00:00Z"}}
\ No newline at end of file
diff --git a/webview/packages/bus/src/protocol.ts b/webview/packages/bus/src/protocol.ts
index 1b01f2e..ad774c2 100644
--- a/webview/packages/bus/src/protocol.ts
+++ b/webview/packages/bus/src/protocol.ts
@@ -13,9 +13,12 @@
* Plan 09-04 bumped 2.2.0 -> 2.3.0 (additive BusInbound cases chatSubmit +
* chatCancelAndSubmit for chat-panel submit + barge-in; additive BusOutbound
* case submitRejected for D-10 text-path rejection toast).
+ *
+ * Local-first LLM routing Task 7 bumped 2.3.0 -> 2.4.0 (additive BusOutbound
+ * case escalated{decision} for HUD escalation badge).
*/
-export const BUS_PROTOCOL_VERSION = "2.3.0";
+export const BUS_PROTOCOL_VERSION = "2.4.0";
export type HudState =
| "idle"
@@ -32,6 +35,42 @@ export type TurnTerminator =
| "errored"
| "superseded";
+/**
+ * Provider identity mirror — `Config.ProviderSelection` (Swift) and
+ * `BusProviderSelection` (Bus). Carried by `EscalationDecision.from` /
+ * `EscalationDecision.to`.
+ */
+export type ProviderSelection = "anthropic" | "ollama";
+
+/**
+ * Detectable Ollama failure modes that trigger one-shot reactive escalation
+ * to Anthropic. Mirror of `AgentCore.OllamaFailureKind` (Swift) and
+ * `BusOllamaFailureKind` (Bus). The HUD escalation badge keys reason copy
+ * off this value.
+ */
+export type OllamaFailureKind =
+ | "streamTruncated"
+ | "malformedToolCall"
+ | "unknownTool"
+ | "refusal"
+ | "connectionFailure"
+ | "emptyResponse";
+
+/**
+ * Local-first LLM routing — payload of `BusOutbound.escalated`. Emitted at
+ * most once per turn when the orchestrator reactively escalates Ollama →
+ * Anthropic. The HUD renders an EscalationBadge keyed by `reason`.
+ *
+ * `firedAt` is an ISO-8601 string (Swift `JSONEncoder` with
+ * `dateEncodingStrategy = .iso8601`).
+ */
+export interface EscalationDecision {
+ from: ProviderSelection;
+ to: ProviderSelection;
+ reason: OllamaFailureKind;
+ firedAt: string;
+}
+
/**
* Local mirror of `Memory.TurnRow` (the backend Swift type) and
* `Bus.TurnRow` (the Swift bus mirror). Bus deliberately does NOT depend on
@@ -64,7 +103,14 @@ export type BusOutbound =
* transient toast. Body string is byte-identical to the voice-path HUD
* banner (single source of truth: RejectReasonCopy.swift).
*/
- | { type: "submitRejected"; reason: string };
+ | { type: "submitRejected"; reason: string }
+ /**
+ * Local-first LLM routing Task 7 — additive case carrying an
+ * `EscalationDecision` for the HUD escalation badge. Emitted at most
+ * once per turn when the orchestrator reactively escalates Ollama →
+ * Anthropic.
+ */
+ | { type: "escalated"; decision: EscalationDecision };
export type BusInbound =
| { type: "helloAck"; version: string }
@@ -115,6 +161,20 @@ const TURN_TERMINATORS = [
"superseded",
] as const satisfies readonly TurnTerminator[];
+const PROVIDER_SELECTIONS = [
+ "anthropic",
+ "ollama",
+] as const satisfies readonly ProviderSelection[];
+
+const OLLAMA_FAILURE_KINDS = [
+ "streamTruncated",
+ "malformedToolCall",
+ "unknownTool",
+ "refusal",
+ "connectionFailure",
+ "emptyResponse",
+] as const satisfies readonly OllamaFailureKind[];
+
function isHudState(v: string): v is HudState {
return (HUD_STATES as readonly string[]).includes(v);
}
@@ -123,6 +183,14 @@ function isTurnTerminator(v: string): v is TurnTerminator {
return (TURN_TERMINATORS as readonly string[]).includes(v);
}
+function isProviderSelection(v: string): v is ProviderSelection {
+ return (PROVIDER_SELECTIONS as readonly string[]).includes(v);
+}
+
+function isOllamaFailureKind(v: string): v is OllamaFailureKind {
+ return (OLLAMA_FAILURE_KINDS as readonly string[]).includes(v);
+}
+
/**
* Decode a BusOutbound JSON string. Never throws: malformed input returns
* `{ok: false, error}`. The default branch hits a `const _exhaustive: never`
@@ -237,6 +305,41 @@ export function decodeOutbound(json: string): DecodeResult {
case "submitRejected":
if (typeof parsed.reason !== "string") return { ok: false, error: "submitRejected: reason must be string" };
return { ok: true, value: { type: "submitRejected", reason: parsed.reason } };
+ case "escalated": {
+ if (!isObject(parsed.decision)) {
+ return { ok: false, error: "escalated: decision must be an object" };
+ }
+ const d = parsed.decision;
+ if (
+ typeof d.from !== "string" ||
+ typeof d.to !== "string" ||
+ typeof d.reason !== "string" ||
+ typeof d.firedAt !== "string"
+ ) {
+ return { ok: false, error: "escalated: invalid decision fields" };
+ }
+ if (!isProviderSelection(d.from)) {
+ return { ok: false, error: `escalated: unknown 'from' provider '${d.from}'` };
+ }
+ if (!isProviderSelection(d.to)) {
+ return { ok: false, error: `escalated: unknown 'to' provider '${d.to}'` };
+ }
+ if (!isOllamaFailureKind(d.reason)) {
+ return { ok: false, error: `escalated: unknown reason '${d.reason}'` };
+ }
+ return {
+ ok: true,
+ value: {
+ type: "escalated",
+ decision: {
+ from: d.from,
+ to: d.to,
+ reason: d.reason,
+ firedAt: d.firedAt,
+ },
+ },
+ };
+ }
default: {
// Compile-time exhaustiveness. After all cases above narrow `type`, the
// default branch should see `type: never`. Adding a new BusOutbound
diff --git a/webview/packages/bus/tests/round-trip.test.ts b/webview/packages/bus/tests/round-trip.test.ts
index cdfb7b2..042067d 100644
--- a/webview/packages/bus/tests/round-trip.test.ts
+++ b/webview/packages/bus/tests/round-trip.test.ts
@@ -19,8 +19,8 @@ function readFixture(name: string): { raw: string; parsed: unknown } {
}
describe("BUS_PROTOCOL_VERSION", () => {
- it("equals 2.3.0 (must match Swift constant — Plan 09-04 chatSubmit + chatCancelAndSubmit + submitRejected bump)", () => {
- expect(BUS_PROTOCOL_VERSION).toBe("2.3.0");
+ it("equals 2.4.0 (must match Swift constant — Local-first LLM routing Task 7 escalated bump)", () => {
+ expect(BUS_PROTOCOL_VERSION).toBe("2.4.0");
});
});
@@ -41,6 +41,7 @@ describe("BusOutbound round-trip", () => {
"turnStarted.json",
"turnEnded.json",
"submitRejected.json",
+ "escalated.json",
];
for (const name of outboundFixtures) {
diff --git a/webview/packages/hud/src/bus/client.ts b/webview/packages/hud/src/bus/client.ts
index e36c244..2f799db 100644
--- a/webview/packages/hud/src/bus/client.ts
+++ b/webview/packages/hud/src/bus/client.ts
@@ -80,14 +80,24 @@ export function attachBus(): void {
let id = store.activeTextPartId
if (!id) {
id = nextTextPartId(turnId, store.chatEvents)
+ // If an escalation decision arrived before any assistant text
+ // event (e.g. connectionFailure on the very first response),
+ // attach it to the freshly-created text part so the badge isn't
+ // dropped. The pending slot is cleared as a side-effect of the
+ // store mutation below.
+ const pending = store.pendingEscalation
store.pushEvent({
kind: 'text',
id,
text: '',
role: 'assistant',
turnId,
+ ...(pending ? { escalation: pending } : {}),
+ })
+ useJarvisStore.setState({
+ activeTextPartId: id,
+ ...(pending ? { pendingEscalation: null } : {}),
})
- useJarvisStore.setState({ activeTextPartId: id })
}
store.appendTokenToLastText(id, msg.text)
break
@@ -155,6 +165,14 @@ export function attachBus(): void {
// hydrate (= WARN-INT-2). Until then we acknowledge but no-op so the
// exhaustiveness sentinel below remains tight.
break
+ case 'escalated':
+ // Local-first LLM routing Task 8 — attach the EscalationDecision
+ // to the most-recent assistant text event. If none exists yet (the
+ // failure fired before the first token), the store parks the
+ // decision and the next assistant text event inherits it (see
+ // tokenDelta arm above).
+ store.attachEscalationToLastAssistantText(msg.decision)
+ break
default: {
const _exhaustive: never = msg
void _exhaustive
diff --git a/webview/packages/hud/src/chat/EscalationBadge.test.tsx b/webview/packages/hud/src/chat/EscalationBadge.test.tsx
new file mode 100644
index 0000000..d598cb6
--- /dev/null
+++ b/webview/packages/hud/src/chat/EscalationBadge.test.tsx
@@ -0,0 +1,32 @@
+import { render, screen } from '@testing-library/react'
+import { describe, it, expect } from 'vitest'
+import { EscalationBadge } from './EscalationBadge'
+
+describe('EscalationBadge', () => {
+ it('renders the failure reason in a friendly form', () => {
+ render()
+ expect(screen.getByText(/malformed tool call/i)).toBeInTheDocument()
+ expect(screen.getByText(/escalated to opus/i)).toBeInTheDocument()
+ })
+
+ it('uses a stable test id for selector use', () => {
+ render()
+ expect(screen.getByTestId('escalation-badge')).toBeInTheDocument()
+ })
+
+ it('renders all six failure kinds without throwing', () => {
+ const reasons = [
+ 'streamTruncated',
+ 'malformedToolCall',
+ 'unknownTool',
+ 'refusal',
+ 'connectionFailure',
+ 'emptyResponse',
+ ] as const
+ for (const r of reasons) {
+ render()
+ }
+ // If any reason was not mapped, render would have thrown.
+ expect(screen.getAllByTestId('escalation-badge').length).toBe(reasons.length)
+ })
+})
diff --git a/webview/packages/hud/src/chat/EscalationBadge.tsx b/webview/packages/hud/src/chat/EscalationBadge.tsx
new file mode 100644
index 0000000..356dafe
--- /dev/null
+++ b/webview/packages/hud/src/chat/EscalationBadge.tsx
@@ -0,0 +1,33 @@
+import type { OllamaFailureKind } from '@jarvis/bus'
+
+/**
+ * EscalationBadge — small inline marker rendered inside an assistant
+ * message when the orchestrator reactively escalated that turn from the
+ * local Ollama provider to Anthropic Opus. The reason string is the
+ * `OllamaFailureKind` carried on `BusOutbound.escalated.decision.reason`;
+ * the friendly map below covers all six kinds defined in
+ * `packages/bus/src/protocol.ts` and `AgentCore.OllamaFailureKind`.
+ *
+ * Visual styling (`.escalation-badge` in `chat-panel.css`) follows the
+ * existing chip pattern — small font, low-contrast amber accent, unobtrusive.
+ */
+const FRIENDLY: Record = {
+ streamTruncated: 'stream truncated',
+ malformedToolCall: 'malformed tool call',
+ unknownTool: 'unknown tool',
+ refusal: 'refusal',
+ connectionFailure: 'connection failure',
+ emptyResponse: 'empty response',
+}
+
+export function EscalationBadge({ reason }: { reason: OllamaFailureKind }) {
+ return (
+
+ Escalated to Opus — {FRIENDLY[reason]}
+
+ )
+}
diff --git a/webview/packages/hud/src/chat/TextPart.tsx b/webview/packages/hud/src/chat/TextPart.tsx
index d281d99..2350b60 100644
--- a/webview/packages/hud/src/chat/TextPart.tsx
+++ b/webview/packages/hud/src/chat/TextPart.tsx
@@ -1,4 +1,5 @@
import type { ChatEvent } from '../store/types'
+import { EscalationBadge } from './EscalationBadge'
type TextEvent = Extract
@@ -9,6 +10,10 @@ type TextEvent = Extract
* output cannot inject markup here (T-03-31). Parent ChatPanel uses
* `key={event.id}` so React reuses this DOM node across streaming updates;
* only the text span's textContent diffs as tokens append.
+ *
+ * When the bus dispatcher attached an `EscalationDecision` to this event
+ * (local-first LLM routing Task 8), an inline EscalationBadge is rendered
+ * as a footer affordance under the message body.
*/
export function TextPart({ event }: { event: TextEvent }) {
return (
@@ -21,6 +26,7 @@ export function TextPart({ event }: { event: TextEvent }) {
{event.role === 'user' ? 'You' : 'Jarvis'}
{event.text}
+ {event.escalation && }
)
}
diff --git a/webview/packages/hud/src/chat/chat-panel.css b/webview/packages/hud/src/chat/chat-panel.css
index 02f1052..c898b81 100644
--- a/webview/packages/hud/src/chat/chat-panel.css
+++ b/webview/packages/hud/src/chat/chat-panel.css
@@ -43,6 +43,23 @@
color: rgba(255, 200, 200, 0.92);
}
+/* Escalation badge — rendered as a footer chip inside an assistant text
+ * event when the orchestrator escalated that turn from Ollama to
+ * Anthropic. Amber accent so it reads as informational, not error. */
+
+.escalation-badge {
+ align-self: flex-start;
+ margin-top: 4px;
+ padding: 2px 8px;
+ border-radius: 10px;
+ background: rgba(245, 166, 35, 0.10);
+ border: 1px solid rgba(245, 166, 35, 0.32);
+ color: rgba(245, 200, 130, 0.92);
+ font-size: 11px;
+ text-transform: none;
+ letter-spacing: 0.02em;
+}
+
/* Tool call card */
.tool-call-card {
diff --git a/webview/packages/hud/src/store/index.ts b/webview/packages/hud/src/store/index.ts
index 68f4dde..af3fe0e 100644
--- a/webview/packages/hud/src/store/index.ts
+++ b/webview/packages/hud/src/store/index.ts
@@ -1,5 +1,6 @@
import { create } from 'zustand'
import { subscribeWithSelector } from 'zustand/middleware'
+import type { EscalationDecision } from '@jarvis/bus'
import type {
A11y,
ChatEvent,
@@ -26,6 +27,14 @@ export interface JarvisState {
* correct text-part after the interruption.
*/
activeTextPartId: string | null
+ /**
+ * Local-first LLM routing (Task 8): when `BusOutbound.escalated`
+ * arrives before any assistant text event exists for the current turn
+ * (e.g. connectionFailure on the very first response), the decision is
+ * parked here and stamped onto the *first* assistant text event created
+ * by a subsequent tokenDelta. Cleared on `beginTurn` and after consumption.
+ */
+ pendingEscalation: EscalationDecision | null
a11y: A11y
theme: Theme
connection: Connection
@@ -45,6 +54,17 @@ export interface JarvisState {
) => void
beginTurn: (id: string) => void
endTurn: () => void
+ /**
+ * Local-first LLM routing (Task 8): attach an EscalationDecision to the
+ * most-recent assistant text event. Called from the bus dispatcher when
+ * `BusOutbound.escalated` arrives. No-op if no assistant text event
+ * exists yet — escalation may fire before any token streamed (e.g.
+ * connectionFailure/refusal on the first response). The Anthropic
+ * stream that follows the escalation will produce assistant text whose
+ * tokenDeltas land *after* this call; for that case the badge is
+ * surfaced on the subsequent text event via a small pending hand-off.
+ */
+ attachEscalationToLastAssistantText: (decision: EscalationDecision) => void
setA11y: (patch: Partial) => void
setTheme: (patch: Partial) => void
setConnection: (c: Connection) => void
@@ -56,6 +76,7 @@ export const useJarvisStore = create()(
chatEvents: [],
currentTurnId: null,
activeTextPartId: null,
+ pendingEscalation: null,
a11y: { reduceMotion: false, reduceTransparency: false },
theme: { arcReactorGlow: '#4FC3F7' },
connection: 'booting',
@@ -111,10 +132,27 @@ export const useJarvisStore = create()(
return { chatEvents: next }
}),
- beginTurn: (id) => set({ currentTurnId: id, activeTextPartId: null }),
+ beginTurn: (id) =>
+ set({ currentTurnId: id, activeTextPartId: null, pendingEscalation: null }),
endTurn: () => set({ currentTurnId: null, activeTextPartId: null }),
+ attachEscalationToLastAssistantText: (decision) =>
+ set((state) => {
+ // Walk from the tail to find the most-recent assistant text event.
+ for (let i = state.chatEvents.length - 1; i >= 0; i--) {
+ const ev = state.chatEvents[i]
+ if (ev && ev.kind === 'text' && ev.role === 'assistant') {
+ const next = state.chatEvents.slice()
+ next[i] = { ...ev, escalation: decision }
+ return { chatEvents: next, pendingEscalation: null }
+ }
+ }
+ // No assistant text yet — park the decision; the next assistant
+ // text event created by tokenDelta inherits it.
+ return { pendingEscalation: decision }
+ }),
+
setA11y: (patch) =>
set((state) => ({ a11y: { ...state.a11y, ...patch } })),
diff --git a/webview/packages/hud/src/store/types.ts b/webview/packages/hud/src/store/types.ts
index bc14768..6917c1e 100644
--- a/webview/packages/hud/src/store/types.ts
+++ b/webview/packages/hud/src/store/types.ts
@@ -1,4 +1,4 @@
-import type { HudState } from '@jarvis/bus'
+import type { EscalationDecision, HudState } from '@jarvis/bus'
export type { HudState }
@@ -9,8 +9,22 @@ export type ToolCallStatus =
| 'completed'
| 'failed'
+/**
+ * Local-first LLM routing (Task 8): when the orchestrator reactively
+ * escalates the active Ollama turn to Anthropic, the bus emits
+ * `BusOutbound.escalated`; the dispatcher attaches the decision to the
+ * most-recent assistant text event so the renderer can stamp an inline
+ * EscalationBadge under that message body.
+ */
export type ChatEvent =
- | { id: string; kind: 'text'; text: string; role: 'user' | 'assistant'; turnId: string }
+ | {
+ id: string
+ kind: 'text'
+ text: string
+ role: 'user' | 'assistant'
+ turnId: string
+ escalation?: EscalationDecision
+ }
| {
id: string
kind: 'tool-call'
diff --git a/webview/packages/hud/tests/bus-dispatch.test.ts b/webview/packages/hud/tests/bus-dispatch.test.ts
index d20d7f3..5e87eef 100644
--- a/webview/packages/hud/tests/bus-dispatch.test.ts
+++ b/webview/packages/hud/tests/bus-dispatch.test.ts
@@ -10,6 +10,9 @@ function resetStore() {
useJarvisStore.setState({
hudState: 'booting',
chatEvents: [],
+ currentTurnId: null,
+ activeTextPartId: null,
+ pendingEscalation: null,
a11y: { reduceMotion: false, reduceTransparency: false },
theme: { arcReactorGlow: '#1E88E5' },
connection: 'booting',
@@ -89,6 +92,55 @@ describe('bus dispatcher — submitRejected pushes error event (B5)', () => {
})
})
+describe('bus dispatcher — escalated attaches to last assistant text (Task 8)', () => {
+ beforeEach(() => {
+ resetStore()
+ installWebkitMock()
+ clearBus()
+ })
+
+ it('attaches the decision to the most-recent assistant text event', () => {
+ attachBus()
+ // Synthesize a turn with an assistant text event in flight.
+ window.jarvisBus.receive('{"type":"turnStarted","id":"t1"}')
+ window.jarvisBus.receive('{"type":"tokenDelta","text":"partial..."}')
+ window.jarvisBus.receive(
+ '{"type":"escalated","decision":{"from":"ollama","to":"anthropic","reason":"streamTruncated","firedAt":"2026-05-14T16:00:00Z"}}',
+ )
+ const events = useJarvisStore.getState().chatEvents
+ const textEv = events.find((e) => e.kind === 'text' && e.role === 'assistant')
+ expect(textEv).toBeDefined()
+ if (textEv && textEv.kind === 'text') {
+ expect(textEv.escalation?.reason).toBe('streamTruncated')
+ expect(textEv.escalation?.from).toBe('ollama')
+ expect(textEv.escalation?.to).toBe('anthropic')
+ }
+ })
+
+ it('parks the decision when no assistant text exists yet, then stamps the next text event', () => {
+ attachBus()
+ window.jarvisBus.receive('{"type":"turnStarted","id":"t2"}')
+ // Escalation fires before any tokenDelta (e.g. connectionFailure).
+ window.jarvisBus.receive(
+ '{"type":"escalated","decision":{"from":"ollama","to":"anthropic","reason":"connectionFailure","firedAt":"2026-05-14T16:00:00Z"}}',
+ )
+ // No text event yet; decision is parked.
+ expect(useJarvisStore.getState().chatEvents.length).toBe(0)
+ expect(useJarvisStore.getState().pendingEscalation?.reason).toBe('connectionFailure')
+
+ // First post-escalation tokenDelta creates the text event and inherits the badge.
+ window.jarvisBus.receive('{"type":"tokenDelta","text":"hello from opus"}')
+ const events = useJarvisStore.getState().chatEvents
+ const textEv = events.find((e) => e.kind === 'text' && e.role === 'assistant')
+ expect(textEv).toBeDefined()
+ if (textEv && textEv.kind === 'text') {
+ expect(textEv.escalation?.reason).toBe('connectionFailure')
+ }
+ // Pending slot was consumed.
+ expect(useJarvisStore.getState().pendingEscalation).toBe(null)
+ })
+})
+
describe('bus dispatcher — decode errors surface (B2)', () => {
beforeEach(() => {
resetStore()