From 48dc664eadfe2d67721e0f1ee8e78c8d4602e992 Mon Sep 17 00:00:00 2001 From: James Maes Date: Thu, 14 May 2026 14:29:41 -0500 Subject: [PATCH 01/10] feat(agent-core): add OllamaFailureKind enum + EscalationDecision struct MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closed enum of six detectable Ollama failure modes that trigger reactive escalation to Anthropic per the local-first routing spec. EscalationDecision is the record emitted once per escalated turn. Refs design spec §2-§3. Co-Authored-By: Claude Opus 4.7 --- .../Sources/AgentCore/OllamaFailureKind.swift | 40 +++++++++++++++++++ .../OllamaFailureKindTests.swift | 30 ++++++++++++++ 2 files changed, 70 insertions(+) create mode 100644 packages/AgentCore/Sources/AgentCore/OllamaFailureKind.swift create mode 100644 packages/AgentCore/Tests/AgentCoreTests/OllamaFailureKindTests.swift 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/Tests/AgentCoreTests/OllamaFailureKindTests.swift b/packages/AgentCore/Tests/AgentCoreTests/OllamaFailureKindTests.swift new file mode 100644 index 0000000..66a86c8 --- /dev/null +++ b/packages/AgentCore/Tests/AgentCoreTests/OllamaFailureKindTests.swift @@ -0,0 +1,30 @@ +import XCTest +@testable import AgentCore + +final class OllamaFailureKindTests: XCTestCase { + func test_failure_kind_round_trips_codable() throws { + let kinds: [OllamaFailureKind] = [ + .streamTruncated, .malformedToolCall, .unknownTool, + .refusal, .connectionFailure, .emptyResponse + ] + for kind in kinds { + let data = try JSONEncoder().encode(kind) + let decoded = try JSONDecoder().decode(OllamaFailureKind.self, from: data) + XCTAssertEqual(decoded, kind) + } + } + + func test_escalation_decision_is_sendable_codable() throws { + let decision = EscalationDecision( + from: .ollama, + to: .anthropic, + reason: .malformedToolCall, + firedAt: Date(timeIntervalSince1970: 1_700_000_000) + ) + let data = try JSONEncoder().encode(decision) + let decoded = try JSONDecoder().decode(EscalationDecision.self, from: data) + XCTAssertEqual(decoded.from, .ollama) + XCTAssertEqual(decoded.to, .anthropic) + XCTAssertEqual(decoded.reason, .malformedToolCall) + } +} -- 2.54.0 From 11169d19eb1e8e3c4b3790ba8ec933b6341b0364 Mon Sep 17 00:00:00 2001 From: James Maes Date: Thu, 14 May 2026 14:33:41 -0500 Subject: [PATCH 02/10] =?UTF-8?q?test(agent-core):=20tighten=20OllamaFailu?= =?UTF-8?q?reKindTests=20=E2=80=94=20allCases=20+=20full=20struct=20round-?= =?UTF-8?q?trip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code-quality review feedback. Loop OllamaFailureKind.allCases so adding a case fails the test by construction. Strengthen EscalationDecision test to assert whole-struct equality and firedAt round-trip. Co-Authored-By: Claude Opus 4.7 --- .../AgentCoreTests/OllamaFailureKindTests.swift | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/packages/AgentCore/Tests/AgentCoreTests/OllamaFailureKindTests.swift b/packages/AgentCore/Tests/AgentCoreTests/OllamaFailureKindTests.swift index 66a86c8..e6ca850 100644 --- a/packages/AgentCore/Tests/AgentCoreTests/OllamaFailureKindTests.swift +++ b/packages/AgentCore/Tests/AgentCoreTests/OllamaFailureKindTests.swift @@ -3,28 +3,24 @@ import XCTest final class OllamaFailureKindTests: XCTestCase { func test_failure_kind_round_trips_codable() throws { - let kinds: [OllamaFailureKind] = [ - .streamTruncated, .malformedToolCall, .unknownTool, - .refusal, .connectionFailure, .emptyResponse - ] - for kind in kinds { + 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_is_sendable_codable() throws { + 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: Date(timeIntervalSince1970: 1_700_000_000) + firedAt: firedAt ) let data = try JSONEncoder().encode(decision) let decoded = try JSONDecoder().decode(EscalationDecision.self, from: data) - XCTAssertEqual(decoded.from, .ollama) - XCTAssertEqual(decoded.to, .anthropic) - XCTAssertEqual(decoded.reason, .malformedToolCall) + XCTAssertEqual(decoded, decision) + XCTAssertEqual(decoded.firedAt, firedAt) } } -- 2.54.0 From e268688d56a87ed6a6896c4f58a39e48a9126348 Mon Sep 17 00:00:00 2001 From: James Maes Date: Thu, 14 May 2026 14:37:19 -0500 Subject: [PATCH 03/10] feat(config): add escalationEnabled field to PerTurnSnapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Defaults to true. Custom decoder uses decodeIfPresent so older configs without the field opt into the new local-first behavior on upgrade. Init parameter default keeps existing call sites source-compatible. Refs design spec §6. Co-Authored-By: Claude Opus 4.7 --- .../Sources/Config/PerTurnSnapshot.swift | 31 +++++++++++++++++ .../ConfigTests/PerTurnSnapshotTests.swift | 34 +++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/packages/Config/Sources/Config/PerTurnSnapshot.swift b/packages/Config/Sources/Config/PerTurnSnapshot.swift index 1d9c8eb..66d59a9 100644 --- a/packages/Config/Sources/Config/PerTurnSnapshot.swift +++ b/packages/Config/Sources/Config/PerTurnSnapshot.swift @@ -3,8 +3,39 @@ 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) + } } diff --git a/packages/Config/Tests/ConfigTests/PerTurnSnapshotTests.swift b/packages/Config/Tests/ConfigTests/PerTurnSnapshotTests.swift index b234205..1b0db50 100644 --- a/packages/Config/Tests/ConfigTests/PerTurnSnapshotTests.swift +++ b/packages/Config/Tests/ConfigTests/PerTurnSnapshotTests.swift @@ -38,6 +38,40 @@ 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) + } +} + enum ConfigTestFixtures { static func launch() throws -> LaunchSnapshot { let json = """ -- 2.54.0 From 3a825009ffdaf0d62f5e2f1dda56ea2849832f45 Mon Sep 17 00:00:00 2001 From: James Maes Date: Thu, 14 May 2026 14:41:17 -0500 Subject: [PATCH 04/10] test(config): assert escalationEnabled survives encode-decode round-trip Guard against a future regression where a custom encode(to:) drops the field. Today encoder symmetry holds via Swift's Codable synthesis; this test pins it. Co-Authored-By: Claude Opus 4.7 --- .../ConfigTests/PerTurnSnapshotTests.swift | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/packages/Config/Tests/ConfigTests/PerTurnSnapshotTests.swift b/packages/Config/Tests/ConfigTests/PerTurnSnapshotTests.swift index 1b0db50..bf1a281 100644 --- a/packages/Config/Tests/ConfigTests/PerTurnSnapshotTests.swift +++ b/packages/Config/Tests/ConfigTests/PerTurnSnapshotTests.swift @@ -70,6 +70,28 @@ final class PerTurnSnapshotEscalationTests: XCTestCase { let snapshot = try JSONDecoder().decode(PerTurnSnapshot.self, from: json) XCTAssertTrue(snapshot.escalationEnabled) } + + 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 { -- 2.54.0 From e9a54eedd4e1844c447a3d15fe1ccc0c35b9ed56 Mon Sep 17 00:00:00 2001 From: James Maes Date: Thu, 14 May 2026 14:45:08 -0500 Subject: [PATCH 05/10] feat(config): default to local-first (ollama + escalation enabled) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the policy half of the local-first routing spec. Pinned-anthropic and pinned-ollama remain valid via Settings (Task 9). Memory extraction and Anthropic API key wiring untouched. Also updates ConfigLoaderTests.test_loadDefaultConfigFromBundle to assert the new ollama default (it asserted .anthropic against the same bundled resource — a mirror of the policy being flipped). Refs design spec §1, §6. Co-Authored-By: Claude Opus 4.7 --- .../Sources/Config/Resources/default-config.json | 3 ++- .../Config/Tests/ConfigTests/ConfigLoaderTests.swift | 2 +- .../Tests/ConfigTests/PerTurnSnapshotTests.swift | 10 ++++++++++ 3 files changed, 13 insertions(+), 2 deletions(-) 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 bf1a281..e86078f 100644 --- a/packages/Config/Tests/ConfigTests/PerTurnSnapshotTests.swift +++ b/packages/Config/Tests/ConfigTests/PerTurnSnapshotTests.swift @@ -71,6 +71,16 @@ final class PerTurnSnapshotEscalationTests: XCTestCase { 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 -- 2.54.0 From 9ddc32548e86a9abbc4ebbc2dee0675ccc84009e Mon Sep 17 00:00:00 2001 From: James Maes Date: Thu, 14 May 2026 15:18:53 -0500 Subject: [PATCH 06/10] feat(ollama): detect malformedToolCall and emptyResponse failure kinds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire detection into the NDJSON parser. Adds two new LLMProviderError cases (`.malformedToolCall(reason:)`, `.emptyResponse`) and an `ollamaFailureKind: OllamaFailureKind?` accessor so the orchestrator (Task 6) can route to Anthropic on these failure modes. NDJSONDecoder tracks `sawAnyTextDelta` / `sawAnyToolUseRequested`; on the `done:true` terminator it emits `.providerError(.emptyResponse)` before `.messageStop` when neither was observed. The tool_calls path now validates string-form `arguments` via `JSONSerialization` and emits `.malformedToolCall(reason:)` on parse failure instead of silently forwarding garbage bytes. OpenAI-compat path is scoped to follow-up — `/api/chat` is the default transport per CLAUDE.md and where reactive escalation lands first. Refs design spec §2. Co-Authored-By: Claude Opus 4.7 --- .../Sources/AgentCore/LLMProviderError.swift | 30 ++++++++ .../AgentOrchestrator/AgentOrchestrator.swift | 4 + .../OllamaProvider/NDJSONDecoder.swift | 53 +++++++++++-- .../NDJSONDecoderTests.swift | 76 +++++++++++++++++++ 4 files changed, 158 insertions(+), 5 deletions(-) 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/AgentOrchestrator/AgentOrchestrator.swift b/packages/AgentCore/Sources/AgentOrchestrator/AgentOrchestrator.swift index ba8c912..561a003 100644 --- a/packages/AgentCore/Sources/AgentOrchestrator/AgentOrchestrator.swift +++ b/packages/AgentCore/Sources/AgentOrchestrator/AgentOrchestrator.swift @@ -920,6 +920,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/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/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)") + } } -- 2.54.0 From 038e35152b2073e4cb201417cd8ffc5dd9486fff Mon Sep 17 00:00:00 2001 From: James Maes Date: Thu, 14 May 2026 15:48:59 -0500 Subject: [PATCH 07/10] feat(orchestrator): reactive escalation from ollama to anthropic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the per-turn retry hook to handle OllamaFailureKind-bearing provider errors via providerError.ollamaFailureKind. When the local Ollama provider emits a malformedToolCall / emptyResponse on a turn with escalationEnabled, the orchestrator allocates a fresh turn row (retryOf the original), swaps to Anthropic, and re-runs the turn from initialMessages. Enforces at-most-one escalation per turn — a second provider failure surfaces a terminal `.error` event. The `escalatedThisTurn` flag is local to `runTurnLoop` so it resets across turns naturally (no sticky cross-turn state). Adds: - `OrchestratorEvent.escalated(EscalationDecision)` — surfaced once per escalation; Task 7 wires this to BusOutbound for the HUD badge. - `PerTurnSnapshot.with(provider:)` — convenience copy used by the orchestrator to swap providers per-turn without mutating ConfigStore. The existing AGENT-09 streamTruncated retry (in `.stopReason`) is left parallel; it triggers on a different LLMEvent shape (transport-mid- stream EOF) and was independently validated by OrchestratorRetryTests. Refs design spec §2, §3. Tests: LocalFirstEscalationTests A/B/C (reactive fire-once / budget honored / disabled passthrough). Co-Authored-By: Claude Opus 4.7 --- .../AgentOrchestrator/AgentOrchestrator.swift | 101 ++++++++ .../DevSnapshotEmitter.swift | 9 + .../AgentOrchestrator/OrchestratorEvent.swift | 8 + .../LocalFirstEscalationTests.swift | 235 ++++++++++++++++++ .../OrchestratorEventBroadcasterTests.swift | 1 + .../TextInputEndToEndTests.swift | 1 + .../Sources/Config/PerTurnSnapshot.swift | 16 ++ 7 files changed, 371 insertions(+) create mode 100644 packages/AgentCore/Tests/AgentOrchestratorTests/LocalFirstEscalationTests.swift diff --git a/packages/AgentCore/Sources/AgentOrchestrator/AgentOrchestrator.swift b/packages/AgentCore/Sources/AgentOrchestrator/AgentOrchestrator.swift index 561a003..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 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/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/Config/Sources/Config/PerTurnSnapshot.swift b/packages/Config/Sources/Config/PerTurnSnapshot.swift index 66d59a9..b824f4e 100644 --- a/packages/Config/Sources/Config/PerTurnSnapshot.swift +++ b/packages/Config/Sources/Config/PerTurnSnapshot.swift @@ -38,4 +38,20 @@ public struct PerTurnSnapshot: Sendable, Codable, Equatable { 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 + ) + } } -- 2.54.0 From a7aae9df77d11e400e5848b47ca12437fd402591 Mon Sep 17 00:00:00 2001 From: James Maes Date: Thu, 14 May 2026 16:21:38 -0500 Subject: [PATCH 08/10] feat(bus): add BusOutbound.escalated event for HUD escalation badge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Local-first LLM routing Task 7. Carries an EscalationDecision from the orchestrator to the HUD so the escalation badge can render the ollama->anthropic transition + reason. BusForwarder gains an .escalated arm that routes the AgentCore-side decision through a new BusForwarderSink.sendEscalated hook; AppBusForwarderSink translates the AgentCore types into the dependency-free Bus mirror types (BusEscalationDecision / BusProviderSelection / BusOllamaFailureKind), preserving the lightweight-bridge-layer invariant. Bus protocol bumped 2.3.0 -> 2.4.0 (MINOR, additive). TS decoder gains matching guards; HUD client adds a no-op acknowledgment for the exhaustiveness sentinel (the EscalationBadge component lands in Task 8). Round-trip coverage: - packages/Bus: fixture-driven Codable round-trip + new BusForwarder tests asserting sendEscalated fires without spurious lifecycle events. - webview/packages/bus: fixture parity + protocol-version test bumped. Refs spec docs/superpowers/specs/2026-05-14-local-first-llm-routing-design.md §4. Co-Authored-By: Claude Opus 4.7 --- App/AppBusForwarderSink.swift | 44 ++++++- App/Resources/webview/index.html | 2 +- .../AgentOrchestrator/BusForwarder.swift | 15 +++ .../BusForwarderTests.swift | 62 +++++++++- packages/Bus/Sources/Bus/BusOutbound.swift | 61 ++++++++++ packages/Bus/Sources/Bus/Protocol.swift | 6 +- .../Bus/Sources/Bus/Resources/Injection.js | 2 +- .../BusTests/CodableRoundTripTests.swift | 20 ++++ .../Tests/BusTests/Fixtures/escalated.json | 1 + webview/packages/bus/fixtures/escalated.json | 1 + webview/packages/bus/src/protocol.ts | 107 +++++++++++++++++- webview/packages/bus/tests/round-trip.test.ts | 5 +- webview/packages/hud/src/bus/client.ts | 6 + 13 files changed, 322 insertions(+), 10 deletions(-) create mode 100644 packages/Bus/Tests/BusTests/Fixtures/escalated.json create mode 100644 webview/packages/bus/fixtures/escalated.json 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/Resources/webview/index.html b/App/Resources/webview/index.html index f00c335..d708fb2 100644 --- a/App/Resources/webview/index.html +++ b/App/Resources/webview/index.html @@ -5,7 +5,7 @@ Jarvis HUD - +
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/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/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/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..ca19570 100644 --- a/webview/packages/hud/src/bus/client.ts +++ b/webview/packages/hud/src/bus/client.ts @@ -155,6 +155,12 @@ 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 7 — additive case carrying an + // `EscalationDecision`. The HUD EscalationBadge is wired in Task 8; + // acknowledging here keeps the exhaustiveness sentinel tight without + // pulling the badge component forward. + break default: { const _exhaustive: never = msg void _exhaustive -- 2.54.0 From 9ff05be0a24b72fb9a828de091455b6cdd3753d5 Mon Sep 17 00:00:00 2001 From: James Maes Date: Thu, 14 May 2026 16:33:41 -0500 Subject: [PATCH 09/10] =?UTF-8?q?feat(hud):=20EscalationBadge=20=E2=80=94?= =?UTF-8?q?=20renders=20ollama=E2=86=92anthropic=20transition=20reason?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a chat turn is escalated, the bus client attaches the EscalationDecision to the most-recent assistant message; the message renderer shows an inline badge with friendly reason text. Six OllamaFailureKind reasons mapped to plain English. If escalation fires before any assistant tokens have streamed (e.g. connectionFailure on the very first response), the decision is parked on the store as pendingEscalation and stamped onto the first text part created by a subsequent tokenDelta — so the badge is never dropped. Refs design spec §4. Co-Authored-By: Claude Opus 4.7 --- App/Resources/webview/index.html | 2 +- webview/packages/hud/src/bus/client.ts | 22 ++++++-- .../hud/src/chat/EscalationBadge.test.tsx | 32 ++++++++++++ .../packages/hud/src/chat/EscalationBadge.tsx | 33 ++++++++++++ webview/packages/hud/src/chat/TextPart.tsx | 6 +++ webview/packages/hud/src/chat/chat-panel.css | 17 ++++++ webview/packages/hud/src/store/index.ts | 40 +++++++++++++- webview/packages/hud/src/store/types.ts | 18 ++++++- .../packages/hud/tests/bus-dispatch.test.ts | 52 +++++++++++++++++++ 9 files changed, 213 insertions(+), 9 deletions(-) create mode 100644 webview/packages/hud/src/chat/EscalationBadge.test.tsx create mode 100644 webview/packages/hud/src/chat/EscalationBadge.tsx diff --git a/App/Resources/webview/index.html b/App/Resources/webview/index.html index d708fb2..61e2600 100644 --- a/App/Resources/webview/index.html +++ b/App/Resources/webview/index.html @@ -5,7 +5,7 @@ Jarvis HUD - +
diff --git a/webview/packages/hud/src/bus/client.ts b/webview/packages/hud/src/bus/client.ts index ca19570..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 @@ -156,10 +166,12 @@ export function attachBus(): void { // exhaustiveness sentinel below remains tight. break case 'escalated': - // Local-first LLM routing Task 7 — additive case carrying an - // `EscalationDecision`. The HUD EscalationBadge is wired in Task 8; - // acknowledging here keeps the exhaustiveness sentinel tight without - // pulling the badge component forward. + // 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 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() -- 2.54.0 From 2f2e9edf9a3d68762d80203edb977cf609bff23e Mon Sep 17 00:00:00 2001 From: James Maes Date: Thu, 14 May 2026 16:51:03 -0500 Subject: [PATCH 10/10] =?UTF-8?q?feat(settings):=20provider=20mode=20picke?= =?UTF-8?q?r=20=E2=80=94=20Local-first=20/=20Anthropic=20only=20/=20Ollama?= =?UTF-8?q?=20only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three-way picker maps to {provider, escalationEnabled} pairs. Wired to ConfigStore for hot-reload. Local-first is the new default (default-config.json). - ProviderMode enum (Local-first / Anthropic only / Ollama only) + bidirectional mapping to {provider, escalationEnabled}. - New Provider section in SettingsView, hydrated from ConfigStore on .onAppear, persists through ConfigStore.updatePerTurn(_:) on .onChange. - PerTurnSnapshot.with(provider:escalationEnabled:) — multi-field copy so the picker can mutate both fields atomically. - ConfigStore threaded through SettingsWindowController.open(...) from AppDelegate.openSettings(). - ProviderModeTests covers canonical mapping, (anthropic, true) edge case normalization, and round-trip via .pair. Refs design spec §6. Co-Authored-By: Claude Opus 4.7 --- App/AppDelegate.swift | 6 +- App/Settings/SettingsView.swift | 114 ++++++++++++++++++ App/Settings/SettingsWindowController.swift | 5 +- App/Tests/AppTests/ProviderModeTests.swift | 51 ++++++++ Jarvis.xcodeproj/project.pbxproj | 4 + .../Sources/Config/PerTurnSnapshot.swift | 19 +++ 6 files changed, 197 insertions(+), 2 deletions(-) create mode 100644 App/Tests/AppTests/ProviderModeTests.swift 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/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/Config/Sources/Config/PerTurnSnapshot.swift b/packages/Config/Sources/Config/PerTurnSnapshot.swift index b824f4e..8fa0f36 100644 --- a/packages/Config/Sources/Config/PerTurnSnapshot.swift +++ b/packages/Config/Sources/Config/PerTurnSnapshot.swift @@ -54,4 +54,23 @@ public struct PerTurnSnapshot: Sendable, Codable, Equatable { 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 + ) + } } -- 2.54.0