diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/ACP/ACPClient.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/ACP/ACPClient.swift index cc279a4..1b5b805 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/ACP/ACPClient.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/ACP/ACPClient.swift @@ -677,6 +677,37 @@ public enum ACPErrorHint { ) } + // Auxiliary task references a provider that isn't authenticated. + // Hermes prints `resolve_provider_client: requested but + // not configured` when an aux task (compression, + // summarization, memory_flush, curator, vision, web_extract, + // session_search, skills_hub) has `provider: ` set in + // config.yaml but that provider's credentials aren't loaded. + // Common after a user removes one OAuth provider while their + // existing config.yaml still names it for an aux task. The + // chat banner used to surface this as `-32603 Internal error` + // with no actionable detail; surface a clear path now. + if let match = haystack.range( + of: #"resolve_provider_client:\s*([a-zA-Z0-9_-]+)\s+requested\s+but"#, + options: .regularExpression + ) { + let line = String(haystack[match]) + // Pull the captured provider name out of the matched line. + // First word after "resolve_provider_client:" is the value. + let provider: String = { + let parts = line.split(whereSeparator: { $0.isWhitespace }) + if let idx = parts.firstIndex(where: { $0.contains("resolve_provider_client") }), + parts.index(after: idx) < parts.endIndex { + let candidate = parts[parts.index(after: idx)] + return String(candidate) + } + return "an unauthenticated provider" + }() + return Classification( + hint: "An auxiliary task is configured to use `\(provider)` but that provider isn't authenticated. Open Settings → Aux Models, or check `~/.hermes/config.yaml` for `auxiliary..provider: \(provider)` and switch it to your active provider (or set it to `auto`)." + ) + } + if haystack.range(of: #"No\s+(Anthropic|OpenAI|OpenRouter|Gemini|Google|Groq|Mistral|XAI)?\s*credentials\s+found"#, options: .regularExpression) != nil || haystack.contains("ANTHROPIC_API_KEY") diff --git a/scarf/scarf/Features/Settings/Views/Tabs/AuxiliaryTab.swift b/scarf/scarf/Features/Settings/Views/Tabs/AuxiliaryTab.swift index 84e718b..3d46f67 100644 --- a/scarf/scarf/Features/Settings/Views/Tabs/AuxiliaryTab.swift +++ b/scarf/scarf/Features/Settings/Views/Tabs/AuxiliaryTab.swift @@ -49,6 +49,79 @@ struct AuxiliaryTab: View { return t } + /// Aux task keys present in `config.yaml` but NOT in `tasks` — + /// e.g. `auxiliary.summarization.provider` from older Hermes + /// versions, or experimental tasks the user added by hand. + /// Without surfacing these, a user whose config has + /// `auxiliary.summarization.provider: nous` (where nous is no + /// longer authenticated) sees the "5 toggles all off" Aux + /// Models tab and concludes nothing's set — but Hermes + /// crashes because it's still resolving the unknown task to + /// a missing provider. Now those tasks render in a + /// fall-through "Other tasks in config.yaml" section. + private var unknownTasks: [String] { + let known = Set(tasks.map(\.key)) + var found: Set = [] + // Scan top-level `auxiliary..` keys. + for line in viewModel.rawConfigYAML.components(separatedBy: "\n") { + let stripped = line.drop(while: { $0 == " " || $0 == "\t" }) + // YAML emits these as either nested under `auxiliary:` + // or as flat dot-paths depending on Hermes' dump style. + // We match either shape by looking for keys after + // `auxiliary.` OR by walking nested indentation. + // Quickest robust approach: regex over the raw text. + if let m = stripped.range(of: #"^auxiliary\.([A-Za-z0-9_]+)\."#, options: .regularExpression) { + let frag = String(stripped[m]) + let parts = frag.split(separator: ".") + if parts.count >= 2 { + found.insert(String(parts[1])) + } + } + } + // Nested form: scan for `auxiliary:` then collect + // direct child keys. + let lines = viewModel.rawConfigYAML.components(separatedBy: "\n") + var inAuxBlock = false + var auxIndent = -1 + for line in lines { + let trimmed = line.drop(while: { $0 == " " || $0 == "\t" }) + let indent = line.count - trimmed.count + if trimmed.hasPrefix("auxiliary:") { + inAuxBlock = true + auxIndent = indent + continue + } + guard inAuxBlock else { continue } + // Out of the aux block when indent drops back to or + // below auxIndent on a non-empty line. + if !trimmed.isEmpty && indent <= auxIndent { + inAuxBlock = false + continue + } + // Direct children of `auxiliary:` are at indent + // > auxIndent and contain a `key:` (no further nesting + // dots before the colon). + if indent > auxIndent, + let colonIdx = trimmed.firstIndex(of: ":") { + let key = trimmed[..