mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-06-04 10:14:47 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5bb39384cb |
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user