diff --git a/container/agent-runner/src/poll-loop.ts b/container/agent-runner/src/poll-loop.ts index bd48db235..cb29edbc3 100644 --- a/container/agent-runner/src/poll-loop.ts +++ b/container/agent-runner/src/poll-loop.ts @@ -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 { 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 { 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 { 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); + } } } } diff --git a/container/agent-runner/src/providers/claude.test.ts b/container/agent-runner/src/providers/claude.test.ts new file mode 100644 index 000000000..4e062558d --- /dev/null +++ b/container/agent-runner/src/providers/claude.test.ts @@ -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'); + }); + }); +}); diff --git a/container/agent-runner/src/providers/claude.ts b/container/agent-runner/src/providers/claude.ts index c9478b8e6..1f67ec134 100644 --- a/container/agent-runner/src/providers/claude.ts +++ b/container/agent-runner/src/providers/claude.ts @@ -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; diff --git a/container/agent-runner/src/providers/types.ts b/container/agent-runner/src/providers/types.ts index 55ab9192f..a41af38e8 100644 --- a/container/agent-runner/src/providers/types.ts +++ b/container/agent-runner/src/providers/types.ts @@ -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; } /**