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()