feat(acp,aux): classify resolve_provider_client errors + surface unknown aux tasks

Two fixes for the user-reported "ACP -32603 Internal error" after
removing a Nous OAuth provider while config.yaml still referenced
nous for an auxiliary task. The actual stderr was clear:

  agent.auxiliary_client: resolve_provider_client: nous requested
    but Nous Portal not configured

But Scarf's chat banner showed only the bare JSON-RPC code and
the user had no actionable path through the UI.

**ACPErrorHint.classify** now pattern-matches the
`resolve_provider_client: <name> requested but` stderr line and
extracts the provider name. Surfaces:

  An auxiliary task is configured to use `<name>` but that
  provider isn't authenticated. Open Settings → Aux Models, or
  check ~/.hermes/config.yaml for auxiliary.<task>.provider: <name>
  and switch it to your active provider (or set it to `auto`).

Routed through the existing chat-banner pipeline that already
catches OAuth revocation and missing-credentials errors.

**AuxiliaryTab** gains an "Other tasks in config.yaml" section
that surfaces aux task keys present in YAML but not in Scarf's
typed list (vision, web_extract, compression, session_search,
skills_hub, approval, mcp, flush_memories, curator). Common
case: `auxiliary.summarization.provider: nous` left over from
older Hermes versions or hand-edited configs. Each unknown task
gets a one-click "Reset provider" button that writes
`auxiliary.<key>.provider: auto` — the most-actionable fix
for the OAuth-removal failure mode. Detection scans both
flat-dot-path and nested YAML shapes so it works regardless of
how Hermes dumped the file.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-05-05 13:00:48 +02:00
parent 4684b9deed
commit bccaba0742
2 changed files with 144 additions and 0 deletions
@@ -677,6 +677,37 @@ public enum ACPErrorHint {
)
}
// Auxiliary task references a provider that isn't authenticated.
// Hermes prints `resolve_provider_client: <name> requested but
// <Display Name> not configured` when an aux task (compression,
// summarization, memory_flush, curator, vision, web_extract,
// session_search, skills_hub) has `provider: <name>` 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.<task>.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")
@@ -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<String> = []
// Scan top-level `auxiliary.<key>.<field>` 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.<key>` 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 `<indent>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[..<colonIdx]
// The first deeper key under `auxiliary:` is a task
// name. We can't easily distinguish "task name" from
// "leaf field" without tracking indent more carefully,
// but only task names sit at indent == auxIndent + 2
// (or +4 with two-space indent). Add the simplest
// heuristic: collect any token that's plausibly a
// task name.
let candidate = String(key).trimmingCharacters(in: .whitespaces)
if !candidate.isEmpty
&& candidate.allSatisfy({ $0.isLetter || $0.isNumber || $0 == "_" }) {
found.insert(candidate)
}
}
}
let unknown = found.subtracting(known)
return unknown.sorted()
}
var body: some View {
Text("Auxiliary tasks use separate, typically cheaper models. Leave Provider as `auto` to inherit the main provider.")
.font(.caption)
@@ -60,6 +133,46 @@ struct AuxiliaryTab: View {
auxRows(for: task.key)
}
}
// Unknown / unrecognised aux tasks present in config.yaml.
// Shown only when at least one such key is present so the
// typical user with a clean config never sees this section.
if !unknownTasks.isEmpty {
SettingsSection(title: "Other tasks in config.yaml", icon: "questionmark.folder") {
Text("These auxiliary tasks are present in your `config.yaml` but Scarf doesn't have a typed editor for them. The most common fix is to reset their provider to `auto` so Hermes inherits the main provider. For finer edits, use **Open in Editor** at the top of Settings.")
.font(.caption)
.foregroundStyle(.secondary)
.padding(.bottom, 4)
ForEach(unknownTasks, id: \.self) { key in
HStack(spacing: 8) {
Image(systemName: "circle.dotted")
.foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 2) {
Text(key)
.font(.system(.body, design: .monospaced, weight: .medium))
Text("Configured under `auxiliary.\(key)` in config.yaml")
.font(.caption2)
.foregroundStyle(.tertiary)
}
Spacer()
// The single most-actionable fix: reset
// provider to `auto`. Solves the v2.7
// user-reported case where removing a
// provider's OAuth left an aux task
// pointing at the now-unauthenticated
// provider, blocking session start with an
// opaque ACP -32603 internal error.
Button("Reset provider") {
viewModel.setAuxiliary(key, field: "provider", value: "auto")
}
.controlSize(.small)
.help(Text(verbatim: "Sets `auxiliary.\(key).provider: auto` so Hermes inherits the main provider's authentication."))
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(.quaternary.opacity(0.3))
}
}
}
Color.clear.frame(height: 0)
.onAppear {
subscription = NousSubscriptionService(context: serverContext).loadState()