Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
412aa1e9ab | ||
|
|
034a115009 |
No files matched your search
@@ -57,6 +57,6 @@ jobs:
|
||||
publish_results: true
|
||||
|
||||
- name: Upload SARIF to code-scanning
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
uses: github/codeql-action/upload-sarif@v4
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
|
||||
- name: Upload SARIF (when present)
|
||||
if: hashFiles('semgrep.sarif') != ''
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
uses: github/codeql-action/upload-sarif@v4
|
||||
with:
|
||||
sarif_file: semgrep.sarif
|
||||
category: semgrep
|
||||
@@ -1,8 +1,6 @@
|
||||
import Foundation
|
||||
import AgentCore // EscalationDecision (AgentCore type)
|
||||
import AgentOrchestrator // BusForwarder, BusForwarderSink
|
||||
import Bus // OutboundBatcher, BusOutbound, TurnTerminator, EscalationDecision (Bus mirror)
|
||||
import Config // ProviderSelection (AgentCore-side)
|
||||
import Bus // OutboundBatcher, BusOutbound, TurnTerminator
|
||||
|
||||
/// App-side adapter that wires `BusForwarder` (pure logic, lives in
|
||||
/// AgentOrchestrator for unit-testability) to `OutboundBatcher` (the
|
||||
@@ -38,21 +36,6 @@ 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
|
||||
@@ -66,29 +49,4 @@ 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3321,11 +3321,7 @@ 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
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'none'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none';" />
|
||||
<title>Jarvis HUD</title>
|
||||
<script defer src="./assets/index-CDTLe5s8.js"></script>
|
||||
<script defer src="./assets/index-ClequpMD.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,54 +1,6 @@
|
||||
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:
|
||||
@@ -76,11 +28,6 @@ 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(
|
||||
@@ -90,7 +37,6 @@ 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
|
||||
@@ -99,7 +45,6 @@ struct SettingsView: View {
|
||||
self.onGrantInputMonitoring = onGrantInputMonitoring
|
||||
self.onHotkeyChanged = onHotkeyChanged
|
||||
self.onPTTHotkeyChanged = onPTTHotkeyChanged
|
||||
self.configStore = configStore
|
||||
self.onClose = onClose
|
||||
}
|
||||
|
||||
@@ -116,13 +61,6 @@ 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
|
||||
@@ -138,10 +76,6 @@ struct SettingsView: View {
|
||||
|
||||
apiKeySection
|
||||
Divider()
|
||||
if configStore != nil {
|
||||
providerModeSection
|
||||
Divider()
|
||||
}
|
||||
permissionsSection
|
||||
Divider()
|
||||
hotkeySection
|
||||
@@ -165,54 +99,6 @@ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import AppKit
|
||||
import SwiftUI
|
||||
import Keychain
|
||||
import Shell
|
||||
import Config
|
||||
|
||||
/// Hosts the `SettingsView` in an `NSWindow`. Distinct from the first-run
|
||||
/// wizard:
|
||||
@@ -32,8 +31,7 @@ public final class SettingsWindowController {
|
||||
validator: AnthropicKeyValidator,
|
||||
onGrantInputMonitoring: @escaping () -> Bool,
|
||||
onHotkeyChanged: @escaping (Shell.KeyboardShortcut?) -> Void,
|
||||
onPTTHotkeyChanged: @escaping (Shell.KeyboardShortcut?) -> Void = { _ in },
|
||||
configStore: ConfigStore? = nil
|
||||
onPTTHotkeyChanged: @escaping (Shell.KeyboardShortcut?) -> Void = { _ in }
|
||||
) {
|
||||
// Refresh truth before view creation: TCC grants and Keychain entries
|
||||
// can change between sessions, same reasoning as the wizard's
|
||||
@@ -53,7 +51,6 @@ public final class SettingsWindowController {
|
||||
onGrantInputMonitoring: onGrantInputMonitoring,
|
||||
onHotkeyChanged: onHotkeyChanged,
|
||||
onPTTHotkeyChanged: onPTTHotkeyChanged,
|
||||
configStore: configStore,
|
||||
onClose: { [weak self] in self?.close() }
|
||||
)
|
||||
let hosting = NSHostingController(rootView: rootView)
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -77,7 +77,6 @@
|
||||
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 */; };
|
||||
@@ -179,7 +178,6 @@
|
||||
4BC1E50BF31BB6CCF895FCA3 /* BrandColors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrandColors.swift; sourceTree = "<group>"; };
|
||||
4DFECDF801143C9F672B5FB7 /* MenuBarIconController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarIconController.swift; sourceTree = "<group>"; };
|
||||
510B6A716A10A8B7CEEC1D3F /* HudStateEnumTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HudStateEnumTests.swift; sourceTree = "<group>"; };
|
||||
4B66305B3801453E88D2341F /* ProviderModeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProviderModeTests.swift; sourceTree = "<group>"; };
|
||||
511D7B2406F485ED0D4B9E0D /* HudStateIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HudStateIntent.swift; sourceTree = "<group>"; };
|
||||
528FED2A364DE6BD1F2BA20F /* AppBusForwarderSink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppBusForwarderSink.swift; sourceTree = "<group>"; };
|
||||
54B2C24637464D380D4450AF /* BootHealthProbes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BootHealthProbes.swift; sourceTree = "<group>"; };
|
||||
@@ -382,7 +380,6 @@
|
||||
46CB8B7693CEA625D7331C3E /* HudStateCoordinatorBusWiringTests.swift */,
|
||||
8B7E85856B679CE0788D38DB /* HudStateCoordinatorTests.swift */,
|
||||
510B6A716A10A8B7CEEC1D3F /* HudStateEnumTests.swift */,
|
||||
4B66305B3801453E88D2341F /* ProviderModeTests.swift */,
|
||||
3527AF52C1E81BAC4EF70766 /* JarvisHUDPanelTests.swift */,
|
||||
6639284829C6DB4F69ED47F7 /* MCPBusGatewayAdapterTests.swift */,
|
||||
0B5B16BDAC02919861AA4B0B /* MCPRuntimeWiringTests.swift */,
|
||||
@@ -1301,7 +1298,6 @@
|
||||
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 */,
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,151 @@
|
||||
# Local-first LLM routing — design
|
||||
|
||||
**Status:** draft 2026-05-14, awaiting user approval
|
||||
**Authors:** James + Claude (Opus 4.7)
|
||||
**Scope:** flip the chat-turn provider default from Anthropic to local Ollama, with reactive escalation to Claude Opus on detectable Ollama failure.
|
||||
|
||||
## Goal
|
||||
|
||||
Default every conversation turn to local Ollama (`qwen2.5-coder:32b-instruct-q8_0`). Escalate to Anthropic Claude Opus 4.7 only when local fails in a way the runtime can detect. The user sees one coherent reply per turn, with a small badge if escalation fired.
|
||||
|
||||
## Why now
|
||||
|
||||
The codebase is already structurally ready:
|
||||
|
||||
- `LLMProvider` protocol is symmetric between Anthropic + Ollama (same `AsyncThrowingStream<LLMEvent>` shape, same `ToolChoice.none` semantics).
|
||||
- `AgentOrchestrator` is already config-driven via `providerFactory(perTurn.resolvedProvider)` (line 311).
|
||||
- A mid-conversation escalation hook already exists — line 793 retries on `streamTruncated` by calling `providerFactory(updated.resolvedProvider)`.
|
||||
- Memory extraction is wired to Ollama separately and is not affected by chat-turn routing.
|
||||
|
||||
This is mostly a policy + UX change, not a new abstraction.
|
||||
|
||||
## Approach
|
||||
|
||||
Reactive fallback only. No predictive routing, no router-model classifier, no self-reported escalation tool. The orchestrator tries Ollama first; if it fails in one of an enumerated set of detectable ways, it retries the turn once on Anthropic, emits an escalation event for the HUD, and surfaces the answer to the user.
|
||||
|
||||
User picked this approach because:
|
||||
|
||||
1. The existing escalation hook is reactive — extending what's there beats inventing a new layer.
|
||||
2. Reactive fallback produces evidence — every escalation is a real-world data point for any future predictive routing.
|
||||
3. Zero added latency on the happy path; no extra LLM call to classify the turn.
|
||||
|
||||
## Spec
|
||||
|
||||
### 1. Default provider flip
|
||||
|
||||
- `default-config.json:9` changes from `"anthropic"` to `"ollama"`.
|
||||
- `PerTurnSnapshot.provider` already exists as the policy carrier.
|
||||
- Ollama target model is `qwen2.5-coder:32b-instruct-q8_0` (the tool-calling baseline pinned by `ModelID.qwen25coder32b`).
|
||||
- Anthropic remains a first-class provider and remains selectable.
|
||||
|
||||
### 2. Escalation trigger taxonomy
|
||||
|
||||
The runtime fires escalation on, and only on, these detectable failure modes (closed enum `OllamaFailureKind`):
|
||||
|
||||
| Trigger | Detection |
|
||||
|---|---|
|
||||
| `streamTruncated` | Existing — already wired at `AgentOrchestrator.swift:793`. |
|
||||
| `malformedToolCall` | `tool_calls` payload fails to decode against `ToolUseRequest`. Known Qwen 3.x footgun per CLAUDE.md. |
|
||||
| `unknownTool` | Model invokes a tool name not in the registry. |
|
||||
| `refusal` | `stopReason: .refusal` event arrives. |
|
||||
| `connectionFailure` | URLSession error before any tokens received (Ollama down, port refused). |
|
||||
| `emptyResponse` | `messageStop` with zero `textDelta` and zero `toolUseRequested`. |
|
||||
|
||||
Explicit non-triggers (do not escalate):
|
||||
|
||||
- Partial answer that "feels weak"
|
||||
- Semantic hallucinations the runtime can't detect
|
||||
- Tool that returned an error (tool failure ≠ model failure)
|
||||
- Slow streaming (no SLA-based escalation)
|
||||
|
||||
These are quality problems the runtime cannot judge. Out of scope for this design.
|
||||
|
||||
### 3. Escalation contract
|
||||
|
||||
```swift
|
||||
struct EscalationDecision: Sendable, Codable {
|
||||
let from: ProviderSelection // .ollama
|
||||
let to: ProviderSelection // .anthropic
|
||||
let reason: OllamaFailureKind
|
||||
let firedAt: Date
|
||||
}
|
||||
```
|
||||
|
||||
`AgentOrchestrator` enforces **at most one escalation per turn**. If the post-escalation Anthropic call also fails, the orchestrator surfaces the error to the user — it does not fall back further. (A second failure means the cloud provider is also down; pretending otherwise is dishonest.)
|
||||
|
||||
### 4. Bus event for the HUD badge
|
||||
|
||||
New outbound event:
|
||||
|
||||
```swift
|
||||
case escalated(EscalationDecision)
|
||||
```
|
||||
|
||||
Emitted once when fallback fires. HUD renders an unobtrusive badge inline with the affected message: "Escalated to Opus — Ollama returned malformed tool call" (or whichever `OllamaFailureKind` fired). The badge stays with that specific message; subsequent messages are visually clean.
|
||||
|
||||
The badge is HUD-side only — it does not affect the chat message content, persistence, or replay log shape.
|
||||
|
||||
### 5. Per-turn vs sticky
|
||||
|
||||
**Per-turn.** Next user message starts fresh on Ollama. Reasoning: one Ollama failure on a Python question doesn't mean the next "what time is it" should go to Opus.
|
||||
|
||||
User can manually pin a conversation to Anthropic via Settings. Manual pin overrides escalation logic; in that mode, no escalation badges fire because no escalation can fire.
|
||||
|
||||
### 6. Settings UX
|
||||
|
||||
Settings → Provider section gets one new control:
|
||||
|
||||
- **Provider preference:** `Local-first (recommended)` / `Anthropic only` / `Ollama only`
|
||||
- `Local-first` is the new default; corresponds to `provider: "ollama"` plus escalation enabled.
|
||||
- `Anthropic only` pins to Anthropic, escalation moot.
|
||||
- `Ollama only` pins to Ollama AND disables escalation — Ollama failures surface as errors. Useful for hard-offline / privacy-strict mode.
|
||||
|
||||
The existing `default-config.json` `provider:` field continues to drive the runtime; new logic decides whether escalation is wired based on a sibling `escalationEnabled` field that defaults to `true` only when `provider == "ollama"`.
|
||||
|
||||
### 7. Testing
|
||||
|
||||
Three eval lanes added to the Harness package:
|
||||
|
||||
- **Reactive fallback fires correctly** — fixture: Ollama returns `malformedToolCall`; assert orchestrator switches to Anthropic mid-turn AND emits one `escalated` bus event.
|
||||
- **Escalation budget honored** — fixture: Ollama fails AND Anthropic also fails on the same turn; assert orchestrator surfaces a structured error, does not loop, emits exactly one `escalated` event.
|
||||
- **Pinned mode disables escalation** — config: `provider=ollama, escalationEnabled=false` + Ollama failure → surface error directly, zero `escalated` events.
|
||||
|
||||
No new fixture corpus needed; leverages existing `FixtureKind` cases in `MockLLMProvider`.
|
||||
|
||||
### 8. Non-goals
|
||||
|
||||
- No predictive routing.
|
||||
- No "stream Anthropic continuing Ollama's partial output" — clean restart only.
|
||||
- No automatic re-Ollama after Anthropic finishes — once escalated, the rest of the turn stays on Anthropic.
|
||||
- No memory extraction changes; that pipeline is independent and stays on Ollama.
|
||||
- No changes to non-chat surfaces (TTS, MCP, voice loop) — they have their own provider stories.
|
||||
- No new GitHub issue label or milestone; this is a v0.5+ migration item that the user wants brought forward.
|
||||
|
||||
### 9. Effort estimate
|
||||
|
||||
Single PR, ~3-4 hours:
|
||||
|
||||
| Subtask | Estimate |
|
||||
|---|---|
|
||||
| `default-config.json` flip + Settings UI control | 30 min |
|
||||
| Orchestrator escalation logic extension + budget tracking | 60 min |
|
||||
| Bus event + HUD badge component | 60 min |
|
||||
| Harness tests (3 lanes) | 60 min |
|
||||
| Docs + comments | 15 min |
|
||||
|
||||
## Success criteria
|
||||
|
||||
- Default fresh-launch experience uses Ollama for chat turns.
|
||||
- A turn that fails on Ollama via any `OllamaFailureKind` automatically retries on Anthropic and the user sees a single coherent reply with a badge.
|
||||
- A turn that fails on both providers shows a structured error, not an infinite retry.
|
||||
- A user with `Ollama only` mode sees the Ollama error directly with no escalation.
|
||||
- All existing tests pass; three new escalation tests pass.
|
||||
- Memory extraction is unaffected.
|
||||
- The chat surface still streams tokens at acceptable latency (no perceptible regression).
|
||||
|
||||
## Open questions for the user
|
||||
|
||||
None blocking — design is concrete enough to implement. If anything below is wrong, push back before the spec is committed.
|
||||
|
||||
- Should the badge wording differ per `OllamaFailureKind`, or just say "Escalated to Opus"? Current design: differentiate, because the reason is small additional information that helps the user understand when Ollama is struggling.
|
||||
- Should the orchestrator emit a single `bus.escalated` per turn, or stream per-attempt diagnostics? Current design: one per turn, keeps the bus contract simple.
|
||||
@@ -22,34 +22,4 @@ 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -521,16 +521,6 @@ 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.
|
||||
@@ -855,97 +845,6 @@ 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
|
||||
@@ -1021,10 +920,6 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,10 +27,6 @@ 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 {
|
||||
@@ -97,13 +93,6 @@ 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
|
||||
}
|
||||
@@ -125,8 +114,4 @@ 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
|
||||
}
|
||||
@@ -157,15 +157,6 @@ 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)
|
||||
}
|
||||
|
||||
@@ -21,14 +21,6 @@ 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 {
|
||||
|
||||
@@ -20,13 +20,6 @@ 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
|
||||
@@ -90,38 +83,12 @@ struct NDJSONDecoder {
|
||||
if let args = function["arguments"] {
|
||||
// Ollama native emits arguments as a JSON object.
|
||||
if let str = args as? String {
|
||||
// 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
|
||||
}
|
||||
argsJSON = Data(str.utf8)
|
||||
} else {
|
||||
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
|
||||
}
|
||||
argsJSON = (try? JSONSerialization.data(
|
||||
withJSONObject: args,
|
||||
options: [.sortedKeys]
|
||||
)) ?? Data("{}".utf8)
|
||||
}
|
||||
} else {
|
||||
argsJSON = Data("{}".utf8)
|
||||
@@ -133,7 +100,6 @@ struct NDJSONDecoder {
|
||||
name: name,
|
||||
argsJSON: argsJSON
|
||||
)))
|
||||
sawAnyToolUseRequested = true
|
||||
}
|
||||
sawToolCalls = true
|
||||
}
|
||||
@@ -143,7 +109,6 @@ 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
|
||||
@@ -171,14 +136,6 @@ 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
|
||||
}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import XCTest
|
||||
@testable import AgentCore
|
||||
|
||||
final class OllamaFailureKindTests: XCTestCase {
|
||||
func test_failure_kind_round_trips_codable() throws {
|
||||
for kind in OllamaFailureKind.allCases {
|
||||
let data = try JSONEncoder().encode(kind)
|
||||
let decoded = try JSONDecoder().decode(OllamaFailureKind.self, from: data)
|
||||
XCTAssertEqual(decoded, kind)
|
||||
}
|
||||
}
|
||||
|
||||
func test_escalation_decision_round_trips_codable() throws {
|
||||
let firedAt = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
let decision = EscalationDecision(
|
||||
from: .ollama,
|
||||
to: .anthropic,
|
||||
reason: .malformedToolCall,
|
||||
firedAt: firedAt
|
||||
)
|
||||
let data = try JSONEncoder().encode(decision)
|
||||
let decoded = try JSONDecoder().decode(EscalationDecision.self, from: data)
|
||||
XCTAssertEqual(decoded, decision)
|
||||
XCTAssertEqual(decoded.firedAt, firedAt)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import XCTest
|
||||
import AgentCore
|
||||
import Config
|
||||
@testable import AgentOrchestrator
|
||||
|
||||
/// Coverage for `BusForwarder.drain` — the .bus subscriber's translation
|
||||
@@ -23,7 +22,6 @@ 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) }
|
||||
@@ -31,18 +29,14 @@ 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],
|
||||
escalations: [EscalationDecision]
|
||||
rejections: [String]
|
||||
) {
|
||||
(tokens, turnStarts, turnEnds, rejections, escalations)
|
||||
(tokens, turnStarts, turnEnds, rejections)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,58 +260,6 @@ 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 {
|
||||
|
||||
@@ -1,235 +0,0 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
-1
@@ -327,7 +327,6 @@ final class OrchestratorEventBroadcasterTests: XCTestCase {
|
||||
case .usage: return "usage"
|
||||
case .turnEnd: return "turnEnd"
|
||||
case .error: return "error"
|
||||
case .escalated: return "escalated"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -211,7 +211,6 @@ final class TextInputEndToEndTests: XCTestCase {
|
||||
case .usage: return "usage"
|
||||
case .turnEnd: return "turnEnd"
|
||||
case .error: return "error"
|
||||
case .escalated: return "escalated"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,80 +181,4 @@ 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)")
|
||||
}
|
||||
}
|
||||
@@ -29,59 +29,6 @@ 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`.
|
||||
@@ -132,7 +79,6 @@ extension BusOutbound: Codable {
|
||||
case turnEnded
|
||||
case sessionHistory
|
||||
case submitRejected
|
||||
case escalated
|
||||
}
|
||||
|
||||
/// All `CodingKeys` across every case. Swift's keyed container does not
|
||||
@@ -152,7 +98,6 @@ extension BusOutbound: Codable {
|
||||
case terminator
|
||||
case turns
|
||||
case reason
|
||||
case decision
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
@@ -194,9 +139,6 @@ 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
|
||||
@@ -254,9 +196,6 @@ 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.
|
||||
|
||||
@@ -25,11 +25,7 @@ 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).
|
||||
///
|
||||
/// 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"
|
||||
public let BUS_PROTOCOL_VERSION: String = "2.3.0"
|
||||
|
||||
/// UI states the HUD can occupy. Encoded as its `rawValue` inside
|
||||
/// `BusOutbound.hudState(_:)` — see `BusOutbound.encode(to:)`.
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"use strict";
|
||||
if (window.jarvisBus) { return; }
|
||||
window.jarvisBus = {
|
||||
protocolVersion: "2.4.0",
|
||||
protocolVersion: "2.3.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
|
||||
|
||||
@@ -234,26 +234,6 @@ 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"}}`.
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
{"type":"escalated","decision":{"from":"ollama","to":"anthropic","reason":"malformedToolCall","firedAt":"2026-05-14T12:00:00Z"}}
|
||||
@@ -3,74 +3,8 @@ import Foundation
|
||||
public struct PerTurnSnapshot: Sendable, Codable, Equatable {
|
||||
public let schemaVersion: Int
|
||||
public let provider: ProviderSelection
|
||||
public let escalationEnabled: Bool
|
||||
public let tts: TTSConfig
|
||||
public let stt: STTConfig
|
||||
public let featureFlags: FeatureFlags
|
||||
// DO NOT add: ollama host, applescript policy, toolBlocklist, confirmationPolicy (all Launch-pinned per SEC-05).
|
||||
|
||||
public init(
|
||||
schemaVersion: Int,
|
||||
provider: ProviderSelection,
|
||||
escalationEnabled: Bool = true,
|
||||
tts: TTSConfig,
|
||||
stt: STTConfig,
|
||||
featureFlags: FeatureFlags
|
||||
) {
|
||||
self.schemaVersion = schemaVersion
|
||||
self.provider = provider
|
||||
self.escalationEnabled = escalationEnabled
|
||||
self.tts = tts
|
||||
self.stt = stt
|
||||
self.featureFlags = featureFlags
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case schemaVersion, provider, escalationEnabled, tts, stt, featureFlags
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.schemaVersion = try c.decode(Int.self, forKey: .schemaVersion)
|
||||
self.provider = try c.decode(ProviderSelection.self, forKey: .provider)
|
||||
self.escalationEnabled = try c.decodeIfPresent(Bool.self, forKey: .escalationEnabled) ?? true
|
||||
self.tts = try c.decode(TTSConfig.self, forKey: .tts)
|
||||
self.stt = try c.decode(STTConfig.self, forKey: .stt)
|
||||
self.featureFlags = try c.decode(FeatureFlags.self, forKey: .featureFlags)
|
||||
}
|
||||
|
||||
/// Local-first LLM routing (Task 6 / spec §2): convenience copy with a
|
||||
/// different provider. Used by `AgentOrchestrator` on reactive
|
||||
/// escalation to swap `.ollama` → `.anthropic` for the same turn
|
||||
/// without mutating the underlying `ConfigStore` (the escalation is a
|
||||
/// per-turn decision, not a config update).
|
||||
public func with(provider: ProviderSelection) -> PerTurnSnapshot {
|
||||
PerTurnSnapshot(
|
||||
schemaVersion: schemaVersion,
|
||||
provider: provider,
|
||||
escalationEnabled: escalationEnabled,
|
||||
tts: tts,
|
||||
stt: stt,
|
||||
featureFlags: featureFlags
|
||||
)
|
||||
}
|
||||
|
||||
/// Local-first LLM routing (Task 9 / spec §6): convenience copy that
|
||||
/// mutates the `{provider, escalationEnabled}` pair atomically. Used
|
||||
/// by the Settings provider-mode picker so a single toggle persists
|
||||
/// both fields through `ConfigStore.updatePerTurn(_:)`. Other fields
|
||||
/// are preserved verbatim.
|
||||
public func with(
|
||||
provider: ProviderSelection,
|
||||
escalationEnabled: Bool
|
||||
) -> PerTurnSnapshot {
|
||||
PerTurnSnapshot(
|
||||
schemaVersion: schemaVersion,
|
||||
provider: provider,
|
||||
escalationEnabled: escalationEnabled,
|
||||
tts: tts,
|
||||
stt: stt,
|
||||
featureFlags: featureFlags
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,7 @@
|
||||
"applescript": {
|
||||
"confirmationRequired": true
|
||||
},
|
||||
"provider": "ollama",
|
||||
"escalationEnabled": true,
|
||||
"provider": "anthropic",
|
||||
"tts": { "tier": "tier1" },
|
||||
"stt": { "whisperKitFallback": false },
|
||||
"featureFlags": {
|
||||
|
||||
@@ -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, .ollama)
|
||||
XCTAssertEqual(perTurn.provider, .anthropic)
|
||||
}
|
||||
|
||||
func test_malformedConfigThrowsMalformed() throws {
|
||||
|
||||
@@ -38,72 +38,6 @@ final class PerTurnSnapshotTests: XCTestCase {
|
||||
}
|
||||
}
|
||||
|
||||
final class PerTurnSnapshotEscalationTests: XCTestCase {
|
||||
func test_escalation_enabled_round_trips() throws {
|
||||
let json = """
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"provider": "ollama",
|
||||
"escalationEnabled": true,
|
||||
"tts": { "tier": "tier1" },
|
||||
"stt": { "whisperKitFallback": false },
|
||||
"featureFlags": { "orpheusTTSEnabled": false, "whisperKitSTTEnabled": false }
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let snapshot = try JSONDecoder().decode(PerTurnSnapshot.self, from: json)
|
||||
XCTAssertEqual(snapshot.provider, .ollama)
|
||||
XCTAssertTrue(snapshot.escalationEnabled)
|
||||
}
|
||||
|
||||
func test_escalation_enabled_defaults_to_true_when_absent() throws {
|
||||
let json = """
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"provider": "ollama",
|
||||
"tts": { "tier": "tier1" },
|
||||
"stt": { "whisperKitFallback": false },
|
||||
"featureFlags": { "orpheusTTSEnabled": false, "whisperKitSTTEnabled": false }
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let snapshot = try JSONDecoder().decode(PerTurnSnapshot.self, from: json)
|
||||
XCTAssertTrue(snapshot.escalationEnabled)
|
||||
}
|
||||
|
||||
func test_default_config_resource_is_local_first() throws {
|
||||
let url = ConfigLoader.bundledDefaultConfigURL()!
|
||||
let data = try Data(contentsOf: url)
|
||||
let snapshot = try JSONDecoder().decode(PerTurnSnapshot.self, from: data)
|
||||
XCTAssertEqual(snapshot.provider, .ollama,
|
||||
"default-config.json must specify ollama as the default chat-turn provider (local-first)")
|
||||
XCTAssertTrue(snapshot.escalationEnabled,
|
||||
"default-config.json must enable escalation for new users (local-first)")
|
||||
}
|
||||
|
||||
func test_escalation_enabled_survives_encode_decode_round_trip_when_false() throws {
|
||||
// Catches a future regression where someone adds a custom encode(to:)
|
||||
// that drops escalationEnabled — encoder must continue emitting the
|
||||
// field so decoder reads the user's explicit `false` rather than
|
||||
// defaulting back to `true`.
|
||||
let original = PerTurnSnapshot(
|
||||
schemaVersion: 1,
|
||||
provider: .ollama,
|
||||
escalationEnabled: false,
|
||||
tts: TTSConfig(tier: "tier1"),
|
||||
stt: STTConfig(whisperKitFallback: false),
|
||||
featureFlags: FeatureFlags(["orpheusTTSEnabled": false, "whisperKitSTTEnabled": false])
|
||||
)
|
||||
|
||||
let encoded = try JSONEncoder().encode(original)
|
||||
let decoded = try JSONDecoder().decode(PerTurnSnapshot.self, from: encoded)
|
||||
|
||||
XCTAssertFalse(decoded.escalationEnabled,
|
||||
"Encoder must emit escalationEnabled so round-trip preserves the user's explicit false")
|
||||
XCTAssertEqual(decoded, original)
|
||||
}
|
||||
}
|
||||
|
||||
enum ConfigTestFixtures {
|
||||
static func launch() throws -> LaunchSnapshot {
|
||||
let json = """
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
{"type":"escalated","decision":{"from":"ollama","to":"anthropic","reason":"malformedToolCall","firedAt":"2026-05-14T12:00:00Z"}}
|
||||
@@ -13,12 +13,9 @@
|
||||
* 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.4.0";
|
||||
export const BUS_PROTOCOL_VERSION = "2.3.0";
|
||||
|
||||
export type HudState =
|
||||
| "idle"
|
||||
@@ -35,42 +32,6 @@ 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
|
||||
@@ -103,14 +64,7 @@ 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 }
|
||||
/**
|
||||
* 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 };
|
||||
| { type: "submitRejected"; reason: string };
|
||||
|
||||
export type BusInbound =
|
||||
| { type: "helloAck"; version: string }
|
||||
@@ -161,20 +115,6 @@ 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);
|
||||
}
|
||||
@@ -183,14 +123,6 @@ 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`
|
||||
@@ -305,41 +237,6 @@ export function decodeOutbound(json: string): DecodeResult<BusOutbound> {
|
||||
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
|
||||
|
||||
@@ -19,8 +19,8 @@ function readFixture(name: string): { raw: string; parsed: unknown } {
|
||||
}
|
||||
|
||||
describe("BUS_PROTOCOL_VERSION", () => {
|
||||
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");
|
||||
it("equals 2.3.0 (must match Swift constant — Plan 09-04 chatSubmit + chatCancelAndSubmit + submitRejected bump)", () => {
|
||||
expect(BUS_PROTOCOL_VERSION).toBe("2.3.0");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,7 +41,6 @@ describe("BusOutbound round-trip", () => {
|
||||
"turnStarted.json",
|
||||
"turnEnded.json",
|
||||
"submitRejected.json",
|
||||
"escalated.json",
|
||||
];
|
||||
|
||||
for (const name of outboundFixtures) {
|
||||
|
||||
@@ -80,24 +80,14 @@ 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
|
||||
@@ -165,14 +155,6 @@ export function attachBus(): void {
|
||||
// hydrate (= WARN-INT-2). Until then we acknowledge but no-op so the
|
||||
// exhaustiveness sentinel below remains tight.
|
||||
break
|
||||
case 'escalated':
|
||||
// Local-first LLM routing Task 8 — attach the EscalationDecision
|
||||
// to the most-recent assistant text event. If none exists yet (the
|
||||
// failure fired before the first token), the store parks the
|
||||
// decision and the next assistant text event inherits it (see
|
||||
// tokenDelta arm above).
|
||||
store.attachEscalationToLastAssistantText(msg.decision)
|
||||
break
|
||||
default: {
|
||||
const _exhaustive: never = msg
|
||||
void _exhaustive
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
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(<EscalationBadge reason="malformedToolCall" />)
|
||||
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(<EscalationBadge reason="streamTruncated" />)
|
||||
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(<EscalationBadge reason={r} />)
|
||||
}
|
||||
// If any reason was not mapped, render would have thrown.
|
||||
expect(screen.getAllByTestId('escalation-badge').length).toBe(reasons.length)
|
||||
})
|
||||
})
|
||||
@@ -1,33 +0,0 @@
|
||||
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<OllamaFailureKind, string> = {
|
||||
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 (
|
||||
<span
|
||||
data-testid="escalation-badge"
|
||||
className="escalation-badge"
|
||||
role="status"
|
||||
>
|
||||
Escalated to Opus — {FRIENDLY[reason]}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { ChatEvent } from '../store/types'
|
||||
import { EscalationBadge } from './EscalationBadge'
|
||||
|
||||
type TextEvent = Extract<ChatEvent, { kind: 'text' }>
|
||||
|
||||
@@ -10,10 +9,6 @@ type TextEvent = Extract<ChatEvent, { kind: 'text' }>
|
||||
* 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 (
|
||||
@@ -26,7 +21,6 @@ export function TextPart({ event }: { event: TextEvent }) {
|
||||
{event.role === 'user' ? 'You' : 'Jarvis'}
|
||||
</span>
|
||||
<span className="chat-panel__text-content">{event.text}</span>
|
||||
{event.escalation && <EscalationBadge reason={event.escalation.reason} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -43,23 +43,6 @@
|
||||
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 {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { create } from 'zustand'
|
||||
import { subscribeWithSelector } from 'zustand/middleware'
|
||||
import type { EscalationDecision } from '@jarvis/bus'
|
||||
import type {
|
||||
A11y,
|
||||
ChatEvent,
|
||||
@@ -27,14 +26,6 @@ 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
|
||||
@@ -54,17 +45,6 @@ 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<A11y>) => void
|
||||
setTheme: (patch: Partial<Theme>) => void
|
||||
setConnection: (c: Connection) => void
|
||||
@@ -76,7 +56,6 @@ export const useJarvisStore = create<JarvisState>()(
|
||||
chatEvents: [],
|
||||
currentTurnId: null,
|
||||
activeTextPartId: null,
|
||||
pendingEscalation: null,
|
||||
a11y: { reduceMotion: false, reduceTransparency: false },
|
||||
theme: { arcReactorGlow: '#4FC3F7' },
|
||||
connection: 'booting',
|
||||
@@ -132,27 +111,10 @@ export const useJarvisStore = create<JarvisState>()(
|
||||
return { chatEvents: next }
|
||||
}),
|
||||
|
||||
beginTurn: (id) =>
|
||||
set({ currentTurnId: id, activeTextPartId: null, pendingEscalation: null }),
|
||||
beginTurn: (id) => set({ currentTurnId: id, activeTextPartId: 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 } })),
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { EscalationDecision, HudState } from '@jarvis/bus'
|
||||
import type { HudState } from '@jarvis/bus'
|
||||
|
||||
export type { HudState }
|
||||
|
||||
@@ -9,22 +9,8 @@ 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
|
||||
escalation?: EscalationDecision
|
||||
}
|
||||
| { id: string; kind: 'text'; text: string; role: 'user' | 'assistant'; turnId: string }
|
||||
| {
|
||||
id: string
|
||||
kind: 'tool-call'
|
||||
|
||||
@@ -10,9 +10,6 @@ function resetStore() {
|
||||
useJarvisStore.setState({
|
||||
hudState: 'booting',
|
||||
chatEvents: [],
|
||||
currentTurnId: null,
|
||||
activeTextPartId: null,
|
||||
pendingEscalation: null,
|
||||
a11y: { reduceMotion: false, reduceTransparency: false },
|
||||
theme: { arcReactorGlow: '#1E88E5' },
|
||||
connection: 'booting',
|
||||
@@ -92,55 +89,6 @@ 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()
|
||||
|
||||
Reference in new issue
Block a user