feat(providers): generalize provider output substitutions

Adds a per-provider `errorSubstitutions` array on AgentProvider — each
entry is a `(name, test, replace)` triple. The poll-loop iterates the
provider's rules (in declaration order) before delivering result text
or error text to the user; the first matching `test` regex wins and
its `replace` string is sent in place of the raw output. If no rule
matches, the original text passes through unchanged — there is no
fallback message.

Replaces the single-purpose `isAuthRequired` / `authRequiredMessage`
shape with a list of typed rules so providers can declare any number
of swap-this-for-that mappings (auth banners, rate-limit hints,
context-too-long errors, etc.). New rule types just add an entry.

ClaudeProvider ships one rule today: `auth-required`, matching Claude
Code's "Not logged in · Please run /login" / "Invalid API key …"
banners with a host-aware remediation (re-run setup or run `claude`
in the project directory).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gavrielc
2026-04-29 18:36:15 +03:00
parent 597e282f88
commit 5bb39384cb
4 changed files with 159 additions and 12 deletions
+57 -11
View File
@@ -21,6 +21,37 @@ function generateId(): string {
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
/**
* Find the first matching substitution rule from the provider, if any.
* Returns the rule (caller logs the name) or null when nothing matches —
* there is intentionally no fallback message.
*/
function findSubstitution(
text: string,
provider: AgentProvider,
): { name: string; replace: string } | null {
for (const rule of provider.errorSubstitutions ?? []) {
if (rule.test.test(text)) return { name: rule.name, replace: rule.replace };
}
return null;
}
function writeSubstitutedMessage(
routing: RoutingContext,
ruleName: string,
text: string,
): void {
log(`Substituting output via rule "${ruleName}"`);
writeMessageOut({
id: generateId(),
kind: 'chat',
platform_id: routing.platformId,
channel_type: routing.channelType,
thread_id: routing.threadId,
content: JSON.stringify({ text }),
});
}
export interface PollLoopConfig {
provider: AgentProvider;
/**
@@ -171,7 +202,7 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
const skippedSet = new Set(skipped);
const processingIds = ids.filter((id) => !commandIds.includes(id) && !skippedSet.has(id));
try {
const result = await processQuery(query, routing, processingIds, config.providerName);
const result = await processQuery(query, routing, processingIds, config.provider, config.providerName);
if (result.continuation && result.continuation !== continuation) {
continuation = result.continuation;
setContinuation(config.providerName, continuation);
@@ -189,15 +220,22 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
clearContinuation(config.providerName);
}
// Write error response so the user knows something went wrong
writeMessageOut({
id: generateId(),
kind: 'chat',
platform_id: routing.platformId,
channel_type: routing.channelType,
thread_id: routing.threadId,
content: JSON.stringify({ text: `Error: ${errMsg}` }),
});
// Write error response so the user knows something went wrong.
// Apply provider-defined substitutions first — e.g. swap "Please run
// /login" for an actionable host-aware message.
const sub = findSubstitution(errMsg, config.provider);
if (sub) {
writeSubstitutedMessage(routing, sub.name, sub.replace);
} else {
writeMessageOut({
id: generateId(),
kind: 'chat',
platform_id: routing.platformId,
channel_type: routing.channelType,
thread_id: routing.threadId,
content: JSON.stringify({ text: `Error: ${errMsg}` }),
});
}
}
// Ensure completed even if processQuery ended without a result event
@@ -249,6 +287,7 @@ async function processQuery(
query: AgentQuery,
routing: RoutingContext,
initialBatchIds: string[],
provider: AgentProvider,
providerName: string,
): Promise<QueryResult> {
let queryContinuation: string | undefined;
@@ -310,7 +349,14 @@ async function processQuery(
// at all — either way the turn is finished.
markCompleted(initialBatchIds);
if (event.text) {
dispatchResultText(event.text, routing);
// Apply provider-defined substitutions before dispatch so banners
// like "Please run /login" surface as actionable host-aware text.
const sub = findSubstitution(event.text, provider);
if (sub) {
writeSubstitutedMessage(routing, sub.name, sub.replace);
} else {
dispatchResultText(event.text, routing);
}
}
}
}
@@ -0,0 +1,47 @@
import { describe, it, expect } from 'bun:test';
import { ClaudeProvider } from './claude.js';
describe('ClaudeProvider.errorSubstitutions', () => {
const provider = new ClaudeProvider();
const findRule = (name: string) => provider.errorSubstitutions.find((r) => r.name === name);
describe('auth-required', () => {
const rule = findRule('auth-required')!;
it('exists', () => {
expect(rule).toBeDefined();
});
it('matches the "Not logged in" banner', () => {
expect(rule.test.test('Not logged in · Please run /login')).toBe(true);
});
it('matches the "Invalid API key" banner', () => {
expect(rule.test.test('Invalid API key · Please run /login')).toBe(true);
});
it('matches with trailing content after the banner', () => {
expect(rule.test.test('Not logged in · Please run /login\n\nstack trace …')).toBe(true);
});
it('does not match when the agent quotes the phrase mid-sentence', () => {
const quoted = "The error 'Invalid API key · Please run /login' means your auth has expired.";
expect(rule.test.test(quoted)).toBe(false);
});
it('does not match when the phrase is wrapped in quotes at the start', () => {
const prose = '"Not logged in · Please run /login" is a Claude Code error.';
expect(rule.test.test(prose)).toBe(false);
});
it('does not match a different separator', () => {
expect(rule.test.test('Not logged in - Please run /login')).toBe(false);
});
it('replace text names the operator remediation', () => {
expect(rule.replace).toContain('Anthropic credentials');
expect(rule.replace).toContain('claude');
});
});
});
+28 -1
View File
@@ -5,7 +5,15 @@ import { query as sdkQuery, type HookCallback, type PreCompactHookInput } from '
import { clearContainerToolInFlight, setContainerToolInFlight } from '../db/connection.js';
import { registerProvider } from './provider-registry.js';
import type { AgentProvider, AgentQuery, McpServerConfig, ProviderEvent, ProviderOptions, QueryInput } from './types.js';
import type {
AgentProvider,
AgentQuery,
ErrorSubstitution,
McpServerConfig,
ProviderEvent,
ProviderOptions,
QueryInput,
} from './types.js';
function log(msg: string): void {
console.error(`[claude-provider] ${msg}`);
@@ -240,8 +248,27 @@ const CLAUDE_CODE_AUTO_COMPACT_WINDOW = process.env.CLAUDE_CODE_AUTO_COMPACT_WIN
*/
const STALE_SESSION_RE = /no conversation found|ENOENT.*\.jsonl|session.*not found/i;
/**
* Provider-specific output substitutions. Each rule is a `(test, replace)`
* pair; the first match wins. The poll-loop applies these to result text
* and to error text before delivery so users see actionable host-aware
* messages instead of raw CLI banners they can't act on from chat.
*/
const ERROR_SUBSTITUTIONS: readonly ErrorSubstitution[] = [
{
name: 'auth-required',
// Anchored to start-of-string with the specific `·` separator (U+00B7)
// the CLI emits, so an agent that quotes the phrase verbatim mid-sentence
// in a normal reply doesn't trip the rule.
test: /^(Not logged in|Invalid API key)\s*·\s*Please run \/login/,
replace:
"I can't reach my Anthropic credentials right now. The operator running NanoClaw needs to re-run setup, or run `claude` in the project directory on the machine I'm running on.",
},
];
export class ClaudeProvider implements AgentProvider {
readonly supportsNativeSlashCommands = true;
readonly errorSubstitutions = ERROR_SUBSTITUTIONS;
private assistantName?: string;
private mcpServers: Record<string, McpServerConfig>;
@@ -14,6 +14,33 @@ export interface AgentProvider {
* (missing transcript, unknown session, etc.) and should be cleared.
*/
isSessionInvalid(err: unknown): boolean;
/**
* Provider-specific (test, replace) pairs applied to result text and
* error text before delivery to the user. Iterated in declaration
* order; the first rule whose `test` matches wins, and its `replace`
* is sent verbatim. If no rule matches, the original text passes
* through unchanged — there is no fallback.
*
* Use this to swap raw SDK/CLI banners that the user can't act on
* (e.g. "Please run /login" — they're not on the host) for actionable
* messages naming the operator's actual remediation path.
*/
errorSubstitutions?: readonly ErrorSubstitution[];
}
/**
* A single rule for swapping raw provider output with a user-facing
* message. Each rule is a `(test, replace)` pair plus a short `name`
* used only for logging when the rule fires.
*/
export interface ErrorSubstitution {
/** Short identifier for logs — e.g. "auth-required", "rate-limited". */
name: string;
/** Regex tested against the error/result text. First match wins. */
test: RegExp;
/** User-facing replacement when `test` matches. */
replace: string;
}
/**