Closes qwibitai/nanoclaw#2325. When the Claude Code SDK auto-compacts the conversation context, the compaction summary tends to drop the agent's learned <message to="…"> wrapping discipline. The destinations table is still populated and the system prompt still lists them, but the behavioral pattern degrades — A2A sends and multi-channel routing silently revert to bare-text or single-channel delivery for the rest of the session, until the next /clear. Three small changes wire a reminder back into the live query when this fires: - New `compacted` event on ProviderEvent. Distinct from `result` so it doesn't mark the turn completed or get dispatched as a chat message (which is also why "Context compacted (N tokens compacted)." stops appearing as noise in user-facing chats — it was a side-effect of reusing the result event path). - ClaudeProvider yields `compacted` instead of `result` for the SDK's compact_boundary system event. - Poll-loop's event handler reacts by pushing a system-tagged reminder back into the active query when there are >1 destinations. Single- destination groups skip the push since they have a fallback that works without wrapping. Tests cover both branches (multi-destination → reminder fires; single-destination → no reminder) using a CompactingProvider that emits the new event mid-stream. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
354 lines
13 KiB
TypeScript
354 lines
13 KiB
TypeScript
import fs from 'fs';
|
|
import path from 'path';
|
|
|
|
import { query as sdkQuery, type HookCallback, type PreCompactHookInput } from '@anthropic-ai/claude-agent-sdk';
|
|
|
|
import { clearContainerToolInFlight, setContainerToolInFlight } from '../db/connection.js';
|
|
import { registerProvider } from './provider-registry.js';
|
|
import type { AgentProvider, AgentQuery, McpServerConfig, ProviderEvent, ProviderOptions, QueryInput } from './types.js';
|
|
|
|
function log(msg: string): void {
|
|
console.error(`[claude-provider] ${msg}`);
|
|
}
|
|
|
|
// Deferred SDK builtins that either sidestep nanoclaw's own scheduling or
|
|
// don't fit our async message-passing model (they're designed for Claude
|
|
// Code's interactive UI and would hang here).
|
|
//
|
|
// - CronCreate / CronDelete / CronList / ScheduleWakeup: we have durable
|
|
// scheduling via mcp__nanoclaw__schedule_task.
|
|
// - AskUserQuestion: SDK returns a placeholder instead of blocking on a
|
|
// real answer — we have mcp__nanoclaw__ask_user_question that persists
|
|
// the question and blocks on the real reply.
|
|
// - EnterPlanMode / ExitPlanMode / EnterWorktree / ExitWorktree: Claude
|
|
// Code UI affordances; in a headless container they'd appear stuck.
|
|
const SDK_DISALLOWED_TOOLS = [
|
|
'CronCreate',
|
|
'CronDelete',
|
|
'CronList',
|
|
'ScheduleWakeup',
|
|
'AskUserQuestion',
|
|
'EnterPlanMode',
|
|
'ExitPlanMode',
|
|
'EnterWorktree',
|
|
'ExitWorktree',
|
|
];
|
|
|
|
// Tool allowlist for NanoClaw agent containers. MCP-tool entries are derived
|
|
// at the call site from the registered `mcpServers` map so that any server
|
|
// added via `add_mcp_server` (or wired in container.json directly) is
|
|
// reachable to the agent — without this, the SDK's allowedTools filter
|
|
// silently drops every MCP namespace not listed here.
|
|
const TOOL_ALLOWLIST = [
|
|
'Bash',
|
|
'Read',
|
|
'Write',
|
|
'Edit',
|
|
'Glob',
|
|
'Grep',
|
|
'WebSearch',
|
|
'WebFetch',
|
|
'Task',
|
|
'TaskOutput',
|
|
'TaskStop',
|
|
'TeamCreate',
|
|
'TeamDelete',
|
|
'SendMessage',
|
|
'TodoWrite',
|
|
'ToolSearch',
|
|
'Skill',
|
|
'NotebookEdit',
|
|
];
|
|
|
|
// MCP server names are sanitized by the SDK when forming tool prefixes:
|
|
// any character outside [A-Za-z0-9_-] becomes '_'. Mirror that here so our
|
|
// allowlist patterns match what the SDK actually exposes.
|
|
function mcpAllowPattern(serverName: string): string {
|
|
return `mcp__${serverName.replace(/[^a-zA-Z0-9_-]/g, '_')}__*`;
|
|
}
|
|
|
|
interface SDKUserMessage {
|
|
type: 'user';
|
|
message: { role: 'user'; content: string };
|
|
parent_tool_use_id: null;
|
|
session_id: string;
|
|
}
|
|
|
|
/**
|
|
* Push-based async iterable for streaming user messages to the Claude SDK.
|
|
*/
|
|
class MessageStream {
|
|
private queue: SDKUserMessage[] = [];
|
|
private waiting: (() => void) | null = null;
|
|
private done = false;
|
|
|
|
push(text: string): void {
|
|
this.queue.push({
|
|
type: 'user',
|
|
message: { role: 'user', content: text },
|
|
parent_tool_use_id: null,
|
|
session_id: '',
|
|
});
|
|
this.waiting?.();
|
|
}
|
|
|
|
end(): void {
|
|
this.done = true;
|
|
this.waiting?.();
|
|
}
|
|
|
|
async *[Symbol.asyncIterator](): AsyncGenerator<SDKUserMessage> {
|
|
while (true) {
|
|
while (this.queue.length > 0) {
|
|
yield this.queue.shift()!;
|
|
}
|
|
if (this.done) return;
|
|
await new Promise<void>((r) => {
|
|
this.waiting = r;
|
|
});
|
|
this.waiting = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Transcript archiving (PreCompact hook) ──
|
|
|
|
interface ParsedMessage {
|
|
role: 'user' | 'assistant';
|
|
content: string;
|
|
}
|
|
|
|
function parseTranscript(content: string): ParsedMessage[] {
|
|
const messages: ParsedMessage[] = [];
|
|
for (const line of content.split('\n')) {
|
|
if (!line.trim()) continue;
|
|
try {
|
|
const entry = JSON.parse(line);
|
|
if (entry.type === 'user' && entry.message?.content) {
|
|
const text = typeof entry.message.content === 'string' ? entry.message.content : entry.message.content.map((c: { text?: string }) => c.text || '').join('');
|
|
if (text) messages.push({ role: 'user', content: text });
|
|
} else if (entry.type === 'assistant' && entry.message?.content) {
|
|
const textParts = entry.message.content.filter((c: { type: string }) => c.type === 'text').map((c: { text: string }) => c.text);
|
|
const text = textParts.join('');
|
|
if (text) messages.push({ role: 'assistant', content: text });
|
|
}
|
|
} catch {
|
|
/* skip unparseable lines */
|
|
}
|
|
}
|
|
return messages;
|
|
}
|
|
|
|
function formatTranscriptMarkdown(messages: ParsedMessage[], title?: string | null, assistantName?: string): string {
|
|
const now = new Date();
|
|
const dateStr = now.toLocaleString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true });
|
|
const lines = [`# ${title || 'Conversation'}`, '', `Archived: ${dateStr}`, '', '---', ''];
|
|
for (const msg of messages) {
|
|
const sender = msg.role === 'user' ? 'User' : assistantName || 'Assistant';
|
|
const content = msg.content.length > 2000 ? msg.content.slice(0, 2000) + '...' : msg.content;
|
|
lines.push(`**${sender}**: ${content}`, '');
|
|
}
|
|
return lines.join('\n');
|
|
}
|
|
|
|
/**
|
|
* PreToolUse hook: record the current tool + its declared timeout so the host
|
|
* sweep can widen its stuck tolerance while Bash is running a long-declared
|
|
* script. Defense-in-depth: if SDK_DISALLOWED_TOOLS slips through somehow,
|
|
* block the call here instead of letting the agent hang.
|
|
*/
|
|
const preToolUseHook: HookCallback = async (input) => {
|
|
const i = input as { tool_name?: string; tool_input?: Record<string, unknown> };
|
|
const toolName = i.tool_name ?? '';
|
|
if (SDK_DISALLOWED_TOOLS.includes(toolName)) {
|
|
return {
|
|
decision: 'block',
|
|
stopReason: `Tool '${toolName}' is not available in this environment — use the nanoclaw equivalent.`,
|
|
} as unknown as ReturnType<HookCallback>;
|
|
}
|
|
// Bash exposes its timeout via the tool_input.timeout field (ms). Any other
|
|
// tool: no declared timeout.
|
|
const declaredTimeoutMs =
|
|
toolName === 'Bash' && typeof i.tool_input?.timeout === 'number' ? (i.tool_input.timeout as number) : null;
|
|
try {
|
|
setContainerToolInFlight(toolName, declaredTimeoutMs);
|
|
} catch (err) {
|
|
log(`PreToolUse: failed to record container_state: ${err instanceof Error ? err.message : String(err)}`);
|
|
}
|
|
return { continue: true };
|
|
};
|
|
|
|
/** Clear in-flight tool on PostToolUse / PostToolUseFailure. */
|
|
const postToolUseHook: HookCallback = async () => {
|
|
try {
|
|
clearContainerToolInFlight();
|
|
} catch (err) {
|
|
log(`PostToolUse: failed to clear container_state: ${err instanceof Error ? err.message : String(err)}`);
|
|
}
|
|
return { continue: true };
|
|
};
|
|
|
|
function createPreCompactHook(assistantName?: string): HookCallback {
|
|
return async (input) => {
|
|
const preCompact = input as PreCompactHookInput;
|
|
const { transcript_path: transcriptPath, session_id: sessionId } = preCompact;
|
|
|
|
if (!transcriptPath || !fs.existsSync(transcriptPath)) {
|
|
log('No transcript found for archiving');
|
|
return {};
|
|
}
|
|
|
|
try {
|
|
const content = fs.readFileSync(transcriptPath, 'utf-8');
|
|
const messages = parseTranscript(content);
|
|
if (messages.length === 0) return {};
|
|
|
|
// Try to get summary from sessions index
|
|
let summary: string | undefined;
|
|
const indexPath = path.join(path.dirname(transcriptPath), 'sessions-index.json');
|
|
if (fs.existsSync(indexPath)) {
|
|
try {
|
|
const index = JSON.parse(fs.readFileSync(indexPath, 'utf-8'));
|
|
summary = index.entries?.find((e: { sessionId: string; summary?: string }) => e.sessionId === sessionId)?.summary;
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
|
|
const name = summary
|
|
? summary.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 50)
|
|
: `conversation-${new Date().getHours().toString().padStart(2, '0')}${new Date().getMinutes().toString().padStart(2, '0')}`;
|
|
|
|
const conversationsDir = '/workspace/agent/conversations';
|
|
fs.mkdirSync(conversationsDir, { recursive: true });
|
|
const filename = `${new Date().toISOString().split('T')[0]}-${name}.md`;
|
|
fs.writeFileSync(path.join(conversationsDir, filename), formatTranscriptMarkdown(messages, summary, assistantName));
|
|
log(`Archived conversation to ${filename}`);
|
|
} catch (err) {
|
|
log(`Failed to archive transcript: ${err instanceof Error ? err.message : String(err)}`);
|
|
}
|
|
return {};
|
|
};
|
|
}
|
|
|
|
// ── Provider ──
|
|
|
|
/**
|
|
* Claude Code auto-compacts context at this window (tokens). Kept here so
|
|
* the generic bootstrap doesn't need to know about Claude-specific env vars.
|
|
*
|
|
* Operator override: set CLAUDE_CODE_AUTO_COMPACT_WINDOW in the host env to
|
|
* raise or lower the threshold without editing source — useful when running
|
|
* with a 1M-context model variant or when emergency-tuning a deployment.
|
|
*/
|
|
const CLAUDE_CODE_AUTO_COMPACT_WINDOW = process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW || '165000';
|
|
|
|
/**
|
|
* Stale-session detection. Matches Claude Code's error text when a
|
|
* resumed session can't be found — missing transcript .jsonl, unknown
|
|
* session ID, etc.
|
|
*/
|
|
const STALE_SESSION_RE = /no conversation found|ENOENT.*\.jsonl|session.*not found/i;
|
|
|
|
export class ClaudeProvider implements AgentProvider {
|
|
readonly supportsNativeSlashCommands = true;
|
|
|
|
private assistantName?: string;
|
|
private mcpServers: Record<string, McpServerConfig>;
|
|
private env: Record<string, string | undefined>;
|
|
private additionalDirectories?: string[];
|
|
|
|
constructor(options: ProviderOptions = {}) {
|
|
this.assistantName = options.assistantName;
|
|
this.mcpServers = options.mcpServers ?? {};
|
|
this.additionalDirectories = options.additionalDirectories;
|
|
this.env = {
|
|
...(options.env ?? {}),
|
|
CLAUDE_CODE_AUTO_COMPACT_WINDOW,
|
|
};
|
|
}
|
|
|
|
isSessionInvalid(err: unknown): boolean {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
return STALE_SESSION_RE.test(msg);
|
|
}
|
|
|
|
query(input: QueryInput): AgentQuery {
|
|
const stream = new MessageStream();
|
|
stream.push(input.prompt);
|
|
|
|
const instructions = input.systemContext?.instructions;
|
|
|
|
const sdkResult = sdkQuery({
|
|
prompt: stream,
|
|
options: {
|
|
cwd: input.cwd,
|
|
additionalDirectories: this.additionalDirectories,
|
|
resume: input.continuation,
|
|
pathToClaudeCodeExecutable: '/pnpm/claude',
|
|
systemPrompt: instructions ? { type: 'preset' as const, preset: 'claude_code' as const, append: instructions } : undefined,
|
|
allowedTools: [
|
|
...TOOL_ALLOWLIST,
|
|
...Object.keys(this.mcpServers).map(mcpAllowPattern),
|
|
],
|
|
disallowedTools: SDK_DISALLOWED_TOOLS,
|
|
env: this.env,
|
|
permissionMode: 'bypassPermissions',
|
|
allowDangerouslySkipPermissions: true,
|
|
settingSources: ['project', 'user'],
|
|
mcpServers: this.mcpServers,
|
|
hooks: {
|
|
PreToolUse: [{ hooks: [preToolUseHook] }],
|
|
PostToolUse: [{ hooks: [postToolUseHook] }],
|
|
PostToolUseFailure: [{ hooks: [postToolUseHook] }],
|
|
PreCompact: [{ hooks: [createPreCompactHook(this.assistantName)] }],
|
|
},
|
|
},
|
|
});
|
|
|
|
let aborted = false;
|
|
|
|
async function* translateEvents(): AsyncGenerator<ProviderEvent> {
|
|
let messageCount = 0;
|
|
for await (const message of sdkResult) {
|
|
if (aborted) return;
|
|
messageCount++;
|
|
|
|
// Yield activity for every SDK event so the poll loop knows the agent is working
|
|
yield { type: 'activity' };
|
|
|
|
if (message.type === 'system' && message.subtype === 'init') {
|
|
yield { type: 'init', continuation: message.session_id };
|
|
} else if (message.type === 'result') {
|
|
const text = 'result' in message ? (message as { result?: string }).result ?? null : null;
|
|
yield { type: 'result', text };
|
|
} else if (message.type === 'system' && (message as { subtype?: string }).subtype === 'api_retry') {
|
|
yield { type: 'error', message: 'API retry', retryable: true };
|
|
} else if (message.type === 'system' && (message as { subtype?: string }).subtype === 'rate_limit_event') {
|
|
yield { type: 'error', message: 'Rate limit', retryable: false, classification: 'quota' };
|
|
} else if (message.type === 'system' && (message as { subtype?: string }).subtype === 'compact_boundary') {
|
|
const meta = (message as { compact_metadata?: { pre_tokens?: number } }).compact_metadata;
|
|
const detail = meta?.pre_tokens ? ` (${meta.pre_tokens.toLocaleString()} tokens compacted)` : '';
|
|
yield { type: 'compacted', text: `Context compacted${detail}.` };
|
|
} else if (message.type === 'system' && (message as { subtype?: string }).subtype === 'task_notification') {
|
|
const tn = message as { summary?: string };
|
|
yield { type: 'progress', message: tn.summary || 'Task notification' };
|
|
}
|
|
}
|
|
log(`Query completed after ${messageCount} SDK messages`);
|
|
}
|
|
|
|
return {
|
|
push: (msg) => stream.push(msg),
|
|
end: () => stream.end(),
|
|
events: translateEvents(),
|
|
abort: () => {
|
|
aborted = true;
|
|
stream.end();
|
|
},
|
|
};
|
|
}
|
|
}
|
|
|
|
registerProvider('claude', (opts) => new ClaudeProvider(opts));
|