refactor(agent-runner): decouple provider interface from Claude specifics
Reshape AgentProvider so provider-specific assumptions stop leaking into the generic layer. No change to what reaches sdkQuery() — same values, different plumbing. - QueryInput: opaque `continuation` replaces `sessionId` + `resumeAt`; `systemContext.instructions` replaces ambiguous `systemPrompt`; `mcpServers`, `env`, `additionalDirectories` move to `ProviderOptions` at construction time. - AgentProvider gains `isSessionInvalid(err)` and `supportsNativeSlashCommands` so the poll-loop stops regex-matching Claude error strings and gates passthrough slash commands per provider. - ClaudeProvider owns `CLAUDE_CODE_AUTO_COMPACT_WINDOW` and the stale-session regex internally. - ProviderEvent.activity kept and documented as the liveness signal (fires on every SDK message so the idle timer stays honest during long tool runs); init carries `continuation` instead of `sessionId`. - poll-loop drops mcpServers/env/systemPrompt from its config; admin user id now passed explicitly. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -40,19 +40,18 @@ const GLOBAL_CLAUDE_MD = '/workspace/global/CLAUDE.md';
|
|||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
const providerName = (process.env.AGENT_PROVIDER || 'claude') as ProviderName;
|
const providerName = (process.env.AGENT_PROVIDER || 'claude') as ProviderName;
|
||||||
const assistantName = process.env.NANOCLAW_ASSISTANT_NAME;
|
const assistantName = process.env.NANOCLAW_ASSISTANT_NAME;
|
||||||
|
const adminUserId = process.env.NANOCLAW_ADMIN_USER_ID;
|
||||||
|
|
||||||
log(`Starting v2 agent-runner (provider: ${providerName})`);
|
log(`Starting v2 agent-runner (provider: ${providerName})`);
|
||||||
|
|
||||||
const provider = createProvider(providerName, { assistantName });
|
|
||||||
|
|
||||||
// Load global CLAUDE.md as additional system context, then append destinations addendum
|
// Load global CLAUDE.md as additional system context, then append destinations addendum
|
||||||
let systemPrompt: string | undefined;
|
let instructions: string | undefined;
|
||||||
if (fs.existsSync(GLOBAL_CLAUDE_MD)) {
|
if (fs.existsSync(GLOBAL_CLAUDE_MD)) {
|
||||||
systemPrompt = fs.readFileSync(GLOBAL_CLAUDE_MD, 'utf-8');
|
instructions = fs.readFileSync(GLOBAL_CLAUDE_MD, 'utf-8');
|
||||||
log('Loaded global CLAUDE.md');
|
log('Loaded global CLAUDE.md');
|
||||||
}
|
}
|
||||||
const addendum = buildSystemPromptAddendum();
|
const addendum = buildSystemPromptAddendum();
|
||||||
systemPrompt = systemPrompt ? `${systemPrompt}\n\n${addendum}` : addendum;
|
instructions = instructions ? `${instructions}\n\n${addendum}` : addendum;
|
||||||
|
|
||||||
// Discover additional directories mounted at /workspace/extra/*
|
// Discover additional directories mounted at /workspace/extra/*
|
||||||
const additionalDirectories: string[] = [];
|
const additionalDirectories: string[] = [];
|
||||||
@@ -73,12 +72,6 @@ async function main(): Promise<void> {
|
|||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const mcpServerPath = path.join(__dirname, 'mcp-tools', 'index.js');
|
const mcpServerPath = path.join(__dirname, 'mcp-tools', 'index.js');
|
||||||
|
|
||||||
// SDK env
|
|
||||||
const env: Record<string, string | undefined> = {
|
|
||||||
...process.env,
|
|
||||||
CLAUDE_CODE_AUTO_COMPACT_WINDOW: '165000',
|
|
||||||
};
|
|
||||||
|
|
||||||
// Build MCP servers config: nanoclaw built-in + any additional from host
|
// Build MCP servers config: nanoclaw built-in + any additional from host
|
||||||
const mcpServers: Record<string, { command: string; args: string[]; env: Record<string, string> }> = {
|
const mcpServers: Record<string, { command: string; args: string[]; env: Record<string, string> }> = {
|
||||||
nanoclaw: {
|
nanoclaw: {
|
||||||
@@ -105,13 +98,18 @@ async function main(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const provider = createProvider(providerName, {
|
||||||
|
assistantName,
|
||||||
|
mcpServers,
|
||||||
|
env: { ...process.env },
|
||||||
|
additionalDirectories: additionalDirectories.length > 0 ? additionalDirectories : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
await runPollLoop({
|
await runPollLoop({
|
||||||
provider,
|
provider,
|
||||||
cwd: CWD,
|
cwd: CWD,
|
||||||
mcpServers,
|
systemContext: { instructions },
|
||||||
systemPrompt,
|
adminUserId,
|
||||||
env,
|
|
||||||
additionalDirectories: additionalDirectories.length > 0 ? additionalDirectories : undefined,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ describe('poll loop integration', () => {
|
|||||||
it('should pick up a message, process it, and write a response', async () => {
|
it('should pick up a message, process it, and write a response', async () => {
|
||||||
insertMessage('m1', { sender: 'Alice', text: 'What is the meaning of life?' }, { platformId: 'chan-1', channelType: 'discord', threadId: 'thread-1' });
|
insertMessage('m1', { sender: 'Alice', text: 'What is the meaning of life?' }, { platformId: 'chan-1', channelType: 'discord', threadId: 'thread-1' });
|
||||||
|
|
||||||
const provider = new MockProvider(() => '<message to="discord-test">42</message>');
|
const provider = new MockProvider({}, () => '<message to="discord-test">42</message>');
|
||||||
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000);
|
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000);
|
||||||
@@ -60,7 +60,7 @@ describe('poll loop integration', () => {
|
|||||||
insertMessage('m1', { sender: 'Alice', text: 'Hello' });
|
insertMessage('m1', { sender: 'Alice', text: 'Hello' });
|
||||||
insertMessage('m2', { sender: 'Bob', text: 'World' });
|
insertMessage('m2', { sender: 'Bob', text: 'World' });
|
||||||
|
|
||||||
const provider = new MockProvider(() => '<message to="discord-test">Got both messages</message>');
|
const provider = new MockProvider({}, () => '<message to="discord-test">Got both messages</message>');
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000);
|
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000);
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@ describe('poll loop integration', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should process messages arriving after loop starts', async () => {
|
it('should process messages arriving after loop starts', async () => {
|
||||||
const provider = new MockProvider(() => '<message to="discord-test">Processed</message>');
|
const provider = new MockProvider({}, () => '<message to="discord-test">Processed</message>');
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 3000);
|
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 3000);
|
||||||
|
|
||||||
@@ -99,8 +99,6 @@ async function runPollLoopWithTimeout(provider: MockProvider, signal: AbortSigna
|
|||||||
runPollLoop({
|
runPollLoop({
|
||||||
provider,
|
provider,
|
||||||
cwd: '/tmp',
|
cwd: '/tmp',
|
||||||
mcpServers: {},
|
|
||||||
env: {},
|
|
||||||
}),
|
}),
|
||||||
new Promise<void>((_, reject) => {
|
new Promise<void>((_, reject) => {
|
||||||
signal.addEventListener('abort', () => reject(new Error('aborted')));
|
signal.addEventListener('abort', () => reject(new Error('aborted')));
|
||||||
|
|||||||
@@ -104,12 +104,10 @@ describe('routing', () => {
|
|||||||
|
|
||||||
describe('mock provider', () => {
|
describe('mock provider', () => {
|
||||||
it('should produce init + result events', async () => {
|
it('should produce init + result events', async () => {
|
||||||
const provider = new MockProvider((prompt) => `Echo: ${prompt}`);
|
const provider = new MockProvider({}, (prompt) => `Echo: ${prompt}`);
|
||||||
const query = provider.query({
|
const query = provider.query({
|
||||||
prompt: 'Hello',
|
prompt: 'Hello',
|
||||||
cwd: '/tmp',
|
cwd: '/tmp',
|
||||||
mcpServers: {},
|
|
||||||
env: {},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const events: Array<{ type: string }> = [];
|
const events: Array<{ type: string }> = [];
|
||||||
@@ -127,12 +125,10 @@ describe('mock provider', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should handle push() during active query', async () => {
|
it('should handle push() during active query', async () => {
|
||||||
const provider = new MockProvider((prompt) => `Re: ${prompt}`);
|
const provider = new MockProvider({}, (prompt) => `Re: ${prompt}`);
|
||||||
const query = provider.query({
|
const query = provider.query({
|
||||||
prompt: 'First',
|
prompt: 'First',
|
||||||
cwd: '/tmp',
|
cwd: '/tmp',
|
||||||
mcpServers: {},
|
|
||||||
env: {},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const events: Array<{ type: string; text?: string }> = [];
|
const events: Array<{ type: string; text?: string }> = [];
|
||||||
@@ -164,12 +160,10 @@ describe('end-to-end with mock provider', () => {
|
|||||||
const prompt = formatMessages(messages);
|
const prompt = formatMessages(messages);
|
||||||
|
|
||||||
// Create mock provider and run query
|
// Create mock provider and run query
|
||||||
const provider = new MockProvider(() => 'The answer is 4');
|
const provider = new MockProvider({}, () => 'The answer is 4');
|
||||||
const query = provider.query({
|
const query = provider.query({
|
||||||
prompt,
|
prompt,
|
||||||
cwd: '/tmp',
|
cwd: '/tmp',
|
||||||
mcpServers: {},
|
|
||||||
env: {},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Process events — simulate what poll-loop does
|
// Process events — simulate what poll-loop does
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { writeMessageOut } from './db/messages-out.js';
|
|||||||
import { touchHeartbeat, clearStaleProcessingAcks } from './db/connection.js';
|
import { touchHeartbeat, clearStaleProcessingAcks } from './db/connection.js';
|
||||||
import { getStoredSessionId, setStoredSessionId, clearStoredSessionId } from './db/session-state.js';
|
import { getStoredSessionId, setStoredSessionId, clearStoredSessionId } from './db/session-state.js';
|
||||||
import { formatMessages, extractRouting, categorizeMessage, type RoutingContext } from './formatter.js';
|
import { formatMessages, extractRouting, categorizeMessage, type RoutingContext } from './formatter.js';
|
||||||
import type { AgentProvider, AgentQuery, McpServerConfig, ProviderEvent } from './providers/types.js';
|
import type { AgentProvider, AgentQuery, ProviderEvent } from './providers/types.js';
|
||||||
|
|
||||||
const POLL_INTERVAL_MS = 1000;
|
const POLL_INTERVAL_MS = 1000;
|
||||||
const ACTIVE_POLL_INTERVAL_MS = 500;
|
const ACTIVE_POLL_INTERVAL_MS = 500;
|
||||||
@@ -21,10 +21,11 @@ function generateId(): string {
|
|||||||
export interface PollLoopConfig {
|
export interface PollLoopConfig {
|
||||||
provider: AgentProvider;
|
provider: AgentProvider;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
mcpServers: Record<string, McpServerConfig>;
|
systemContext?: {
|
||||||
systemPrompt?: string;
|
instructions?: string;
|
||||||
env: Record<string, string | undefined>;
|
};
|
||||||
additionalDirectories?: string[];
|
/** Admin user ID for permission checks on admin commands (e.g. /clear). */
|
||||||
|
adminUserId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -38,15 +39,14 @@ export interface PollLoopConfig {
|
|||||||
* 6. Loop
|
* 6. Loop
|
||||||
*/
|
*/
|
||||||
export async function runPollLoop(config: PollLoopConfig): Promise<void> {
|
export async function runPollLoop(config: PollLoopConfig): Promise<void> {
|
||||||
// Resume the SDK session from a prior container run if one was persisted.
|
// Resume the agent's prior session from a previous container run if one
|
||||||
// The SDK's .jsonl transcripts live in the shared ~/.claude mount, so the
|
// was persisted. The continuation is opaque to the poll-loop — the
|
||||||
// conversation history is already on disk — we just need the session ID
|
// provider decides how to use it (Claude resumes a .jsonl transcript,
|
||||||
// to tell the SDK which one to continue.
|
// other providers may reload a thread ID, etc.).
|
||||||
let sessionId: string | undefined = getStoredSessionId();
|
let continuation: string | undefined = getStoredSessionId();
|
||||||
let resumeAt: string | undefined;
|
|
||||||
|
|
||||||
if (sessionId) {
|
if (continuation) {
|
||||||
log(`Resuming SDK session ${sessionId}`);
|
log(`Resuming agent session ${continuation}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear leftover 'processing' acks from a previous crashed container.
|
// Clear leftover 'processing' acks from a previous crashed container.
|
||||||
@@ -75,7 +75,7 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
|
|||||||
const routing = extractRouting(messages);
|
const routing = extractRouting(messages);
|
||||||
|
|
||||||
// Handle commands: categorize chat messages
|
// Handle commands: categorize chat messages
|
||||||
const adminUserId = config.env.NANOCLAW_ADMIN_USER_ID;
|
const adminUserId = config.adminUserId;
|
||||||
const normalMessages = [];
|
const normalMessages = [];
|
||||||
const commandIds: string[] = [];
|
const commandIds: string[] = [];
|
||||||
|
|
||||||
@@ -110,9 +110,8 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
|
|||||||
}
|
}
|
||||||
// Handle admin commands directly
|
// Handle admin commands directly
|
||||||
if (cmdInfo.command === '/clear') {
|
if (cmdInfo.command === '/clear') {
|
||||||
log('Clearing session (resetting sessionId)');
|
log('Clearing session (resetting continuation)');
|
||||||
sessionId = undefined;
|
continuation = undefined;
|
||||||
resumeAt = undefined;
|
|
||||||
clearStoredSessionId();
|
clearStoredSessionId();
|
||||||
writeMessageOut({
|
writeMessageOut({
|
||||||
id: generateId(),
|
id: generateId(),
|
||||||
@@ -149,43 +148,37 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format messages: passthrough commands get raw text, others get XML
|
// Format messages: passthrough commands get raw text (only if the
|
||||||
const prompt = formatMessagesWithCommands(normalMessages);
|
// provider natively handles slash commands), others get XML.
|
||||||
|
const prompt = formatMessagesWithCommands(normalMessages, config.provider.supportsNativeSlashCommands);
|
||||||
|
|
||||||
log(`Processing ${normalMessages.length} message(s), kinds: ${[...new Set(normalMessages.map((m) => m.kind))].join(',')}`);
|
log(`Processing ${normalMessages.length} message(s), kinds: ${[...new Set(normalMessages.map((m) => m.kind))].join(',')}`);
|
||||||
|
|
||||||
const query = config.provider.query({
|
const query = config.provider.query({
|
||||||
prompt,
|
prompt,
|
||||||
sessionId,
|
continuation,
|
||||||
resumeAt,
|
|
||||||
cwd: config.cwd,
|
cwd: config.cwd,
|
||||||
mcpServers: config.mcpServers,
|
systemContext: config.systemContext,
|
||||||
systemPrompt: config.systemPrompt,
|
|
||||||
env: config.env,
|
|
||||||
additionalDirectories: config.additionalDirectories,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Process the query while concurrently polling for new messages
|
// Process the query while concurrently polling for new messages
|
||||||
const processingIds = ids.filter((id) => !commandIds.includes(id));
|
const processingIds = ids.filter((id) => !commandIds.includes(id));
|
||||||
try {
|
try {
|
||||||
const result = await processQuery(query, routing, config, processingIds);
|
const result = await processQuery(query, routing, config, processingIds);
|
||||||
if (result.sessionId && result.sessionId !== sessionId) {
|
if (result.continuation && result.continuation !== continuation) {
|
||||||
sessionId = result.sessionId;
|
continuation = result.continuation;
|
||||||
setStoredSessionId(sessionId);
|
setStoredSessionId(continuation);
|
||||||
}
|
}
|
||||||
if (result.resumeAt) resumeAt = result.resumeAt;
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errMsg = err instanceof Error ? err.message : String(err);
|
const errMsg = err instanceof Error ? err.message : String(err);
|
||||||
log(`Query error: ${errMsg}`);
|
log(`Query error: ${errMsg}`);
|
||||||
|
|
||||||
// Stale/corrupt session recovery: if the SDK can't find the session
|
// Stale/corrupt continuation recovery: ask the provider whether
|
||||||
// we asked it to resume, clear the stored ID so the next attempt
|
// this error means the stored continuation is unusable, and clear
|
||||||
// starts fresh. The transcript .jsonl can go missing after a crash
|
// it so the next attempt starts fresh.
|
||||||
// mid-write, manual deletion, or disk-full.
|
if (continuation && config.provider.isSessionInvalid(err)) {
|
||||||
if (sessionId && /no conversation found|ENOENT.*\.jsonl|session.*not found/i.test(errMsg)) {
|
log(`Stale session detected (${continuation}) — clearing for next retry`);
|
||||||
log(`Stale session detected (${sessionId}) — clearing for next retry`);
|
continuation = undefined;
|
||||||
sessionId = undefined;
|
|
||||||
resumeAt = undefined;
|
|
||||||
clearStoredSessionId();
|
clearStoredSessionId();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,17 +200,16 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Format messages, handling passthrough commands differently.
|
* Format messages, handling passthrough commands differently.
|
||||||
* Passthrough commands (e.g., /foo) are sent raw (no XML wrapping).
|
* When the provider handles slash commands natively (Claude Code),
|
||||||
* Admin commands from authorized users are formatted as system commands.
|
* passthrough commands are sent raw (no XML wrapping) so the SDK can
|
||||||
* Normal messages get standard XML formatting.
|
* dispatch them. Otherwise they fall through to standard XML formatting.
|
||||||
*/
|
*/
|
||||||
function formatMessagesWithCommands(messages: MessageInRow[]): string {
|
function formatMessagesWithCommands(messages: MessageInRow[], nativeSlashCommands: boolean): string {
|
||||||
// Check if any message is a passthrough command
|
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
const normalBatch: MessageInRow[] = [];
|
const normalBatch: MessageInRow[] = [];
|
||||||
|
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
if (msg.kind === 'chat' || msg.kind === 'chat-sdk') {
|
if (nativeSlashCommands && (msg.kind === 'chat' || msg.kind === 'chat-sdk')) {
|
||||||
const cmdInfo = categorizeMessage(msg);
|
const cmdInfo = categorizeMessage(msg);
|
||||||
if (cmdInfo.category === 'passthrough' || cmdInfo.category === 'admin') {
|
if (cmdInfo.category === 'passthrough' || cmdInfo.category === 'admin') {
|
||||||
// Flush normal batch first
|
// Flush normal batch first
|
||||||
@@ -241,12 +233,11 @@ function formatMessagesWithCommands(messages: MessageInRow[]): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface QueryResult {
|
interface QueryResult {
|
||||||
sessionId?: string;
|
continuation?: string;
|
||||||
resumeAt?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function processQuery(query: AgentQuery, routing: RoutingContext, config: PollLoopConfig, processingIds: string[]): Promise<QueryResult> {
|
async function processQuery(query: AgentQuery, routing: RoutingContext, config: PollLoopConfig, processingIds: string[]): Promise<QueryResult> {
|
||||||
let querySessionId: string | undefined;
|
let queryContinuation: string | undefined;
|
||||||
let done = false;
|
let done = false;
|
||||||
let lastEventTime = Date.now();
|
let lastEventTime = Date.now();
|
||||||
|
|
||||||
@@ -289,7 +280,7 @@ async function processQuery(query: AgentQuery, routing: RoutingContext, config:
|
|||||||
touchHeartbeat();
|
touchHeartbeat();
|
||||||
|
|
||||||
if (event.type === 'init') {
|
if (event.type === 'init') {
|
||||||
querySessionId = event.sessionId;
|
queryContinuation = event.continuation;
|
||||||
} else if (event.type === 'result' && event.text) {
|
} else if (event.type === 'result' && event.text) {
|
||||||
dispatchResultText(event.text, routing);
|
dispatchResultText(event.text, routing);
|
||||||
}
|
}
|
||||||
@@ -299,13 +290,13 @@ async function processQuery(query: AgentQuery, routing: RoutingContext, config:
|
|||||||
clearInterval(pollHandle);
|
clearInterval(pollHandle);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { sessionId: querySessionId };
|
return { continuation: queryContinuation };
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleEvent(event: ProviderEvent, _routing: RoutingContext): void {
|
function handleEvent(event: ProviderEvent, _routing: RoutingContext): void {
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case 'init':
|
case 'init':
|
||||||
log(`Session: ${event.sessionId}`);
|
log(`Session: ${event.continuation}`);
|
||||||
break;
|
break;
|
||||||
case 'result':
|
case 'result':
|
||||||
log(`Result: ${event.text ? event.text.slice(0, 200) : '(empty)'}`);
|
log(`Result: ${event.text ? event.text.slice(0, 200) : '(empty)'}`);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import path from 'path';
|
|||||||
|
|
||||||
import { query as sdkQuery, type HookCallback, type PreCompactHookInput } from '@anthropic-ai/claude-agent-sdk';
|
import { query as sdkQuery, type HookCallback, type PreCompactHookInput } from '@anthropic-ai/claude-agent-sdk';
|
||||||
|
|
||||||
import type { AgentProvider, AgentQuery, ProviderEvent, QueryInput } from './types.js';
|
import type { AgentProvider, AgentQuery, McpServerConfig, ProviderEvent, ProviderOptions, QueryInput } from './types.js';
|
||||||
|
|
||||||
function log(msg: string): void {
|
function log(msg: string): void {
|
||||||
console.error(`[claude-provider] ${msg}`);
|
console.error(`[claude-provider] ${msg}`);
|
||||||
@@ -161,31 +161,61 @@ function createPreCompactHook(assistantName?: string): HookCallback {
|
|||||||
|
|
||||||
// ── Provider ──
|
// ── Provider ──
|
||||||
|
|
||||||
export class ClaudeProvider implements AgentProvider {
|
/**
|
||||||
private assistantName?: string;
|
* 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.
|
||||||
|
*/
|
||||||
|
const CLAUDE_CODE_AUTO_COMPACT_WINDOW = '165000';
|
||||||
|
|
||||||
constructor(opts?: { assistantName?: string }) {
|
/**
|
||||||
this.assistantName = opts?.assistantName;
|
* 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 {
|
query(input: QueryInput): AgentQuery {
|
||||||
const stream = new MessageStream();
|
const stream = new MessageStream();
|
||||||
stream.push(input.prompt);
|
stream.push(input.prompt);
|
||||||
|
|
||||||
|
const instructions = input.systemContext?.instructions;
|
||||||
|
|
||||||
const sdkResult = sdkQuery({
|
const sdkResult = sdkQuery({
|
||||||
prompt: stream,
|
prompt: stream,
|
||||||
options: {
|
options: {
|
||||||
cwd: input.cwd,
|
cwd: input.cwd,
|
||||||
additionalDirectories: input.additionalDirectories,
|
additionalDirectories: this.additionalDirectories,
|
||||||
resume: input.sessionId,
|
resume: input.continuation,
|
||||||
resumeSessionAt: input.resumeAt,
|
systemPrompt: instructions ? { type: 'preset' as const, preset: 'claude_code' as const, append: instructions } : undefined,
|
||||||
systemPrompt: input.systemPrompt ? { type: 'preset' as const, preset: 'claude_code' as const, append: input.systemPrompt } : undefined,
|
|
||||||
allowedTools: TOOL_ALLOWLIST,
|
allowedTools: TOOL_ALLOWLIST,
|
||||||
env: input.env,
|
env: this.env,
|
||||||
permissionMode: 'bypassPermissions',
|
permissionMode: 'bypassPermissions',
|
||||||
allowDangerouslySkipPermissions: true,
|
allowDangerouslySkipPermissions: true,
|
||||||
settingSources: ['project', 'user'],
|
settingSources: ['project', 'user'],
|
||||||
mcpServers: input.mcpServers,
|
mcpServers: this.mcpServers,
|
||||||
hooks: {
|
hooks: {
|
||||||
PreCompact: [{ hooks: [createPreCompactHook(this.assistantName)] }],
|
PreCompact: [{ hooks: [createPreCompactHook(this.assistantName)] }],
|
||||||
},
|
},
|
||||||
@@ -204,7 +234,7 @@ export class ClaudeProvider implements AgentProvider {
|
|||||||
yield { type: 'activity' };
|
yield { type: 'activity' };
|
||||||
|
|
||||||
if (message.type === 'system' && message.subtype === 'init') {
|
if (message.type === 'system' && message.subtype === 'init') {
|
||||||
yield { type: 'init', sessionId: message.session_id };
|
yield { type: 'init', continuation: message.session_id };
|
||||||
} else if (message.type === 'result') {
|
} else if (message.type === 'result') {
|
||||||
const text = 'result' in message ? (message as { result?: string }).result ?? null : null;
|
const text = 'result' in message ? (message as { result?: string }).result ?? null : null;
|
||||||
yield { type: 'result', text };
|
yield { type: 'result', text };
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import type { AgentProvider } from './types.js';
|
import type { AgentProvider, ProviderOptions } from './types.js';
|
||||||
import { ClaudeProvider } from './claude.js';
|
import { ClaudeProvider } from './claude.js';
|
||||||
import { MockProvider } from './mock.js';
|
import { MockProvider } from './mock.js';
|
||||||
|
|
||||||
export type ProviderName = 'claude' | 'mock';
|
export type ProviderName = 'claude' | 'mock';
|
||||||
|
|
||||||
export function createProvider(name: ProviderName, opts?: { assistantName?: string }): AgentProvider {
|
export function createProvider(name: ProviderName, options: ProviderOptions = {}): AgentProvider {
|
||||||
switch (name) {
|
switch (name) {
|
||||||
case 'claude':
|
case 'claude':
|
||||||
return new ClaudeProvider(opts);
|
return new ClaudeProvider(options);
|
||||||
case 'mock':
|
case 'mock':
|
||||||
return new MockProvider();
|
return new MockProvider(options);
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unknown provider: ${name}`);
|
throw new Error(`Unknown provider: ${name}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,22 @@
|
|||||||
import type { AgentProvider, AgentQuery, ProviderEvent, QueryInput } from './types.js';
|
import type { AgentProvider, AgentQuery, ProviderEvent, ProviderOptions, QueryInput } from './types.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mock provider for testing. Returns canned responses.
|
* Mock provider for testing. Returns canned responses.
|
||||||
* Supports push() — queued messages produce additional results.
|
* Supports push() — queued messages produce additional results.
|
||||||
*/
|
*/
|
||||||
export class MockProvider implements AgentProvider {
|
export class MockProvider implements AgentProvider {
|
||||||
|
readonly supportsNativeSlashCommands = false;
|
||||||
|
|
||||||
private responseFactory: (prompt: string) => string;
|
private responseFactory: (prompt: string) => string;
|
||||||
|
|
||||||
constructor(responseFactory?: (prompt: string) => string) {
|
constructor(_options: ProviderOptions = {}, responseFactory?: (prompt: string) => string) {
|
||||||
this.responseFactory = responseFactory ?? ((prompt) => `Mock response to: ${prompt.slice(0, 100)}`);
|
this.responseFactory = responseFactory ?? ((prompt) => `Mock response to: ${prompt.slice(0, 100)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isSessionInvalid(_err: unknown): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
query(input: QueryInput): AgentQuery {
|
query(input: QueryInput): AgentQuery {
|
||||||
const pending: string[] = [];
|
const pending: string[] = [];
|
||||||
let waiting: (() => void) | null = null;
|
let waiting: (() => void) | null = null;
|
||||||
@@ -21,7 +27,7 @@ export class MockProvider implements AgentProvider {
|
|||||||
const events: AsyncIterable<ProviderEvent> = {
|
const events: AsyncIterable<ProviderEvent> = {
|
||||||
async *[Symbol.asyncIterator]() {
|
async *[Symbol.asyncIterator]() {
|
||||||
yield { type: 'activity' };
|
yield { type: 'activity' };
|
||||||
yield { type: 'init', sessionId: `mock-session-${Date.now()}` };
|
yield { type: 'init', continuation: `mock-session-${Date.now()}` };
|
||||||
|
|
||||||
// Process initial prompt
|
// Process initial prompt
|
||||||
yield { type: 'activity' };
|
yield { type: 'activity' };
|
||||||
|
|||||||
@@ -1,32 +1,52 @@
|
|||||||
export interface AgentProvider {
|
export interface AgentProvider {
|
||||||
|
/**
|
||||||
|
* True if the provider's underlying SDK handles slash commands natively and
|
||||||
|
* wants them passed through as raw text. When false, the poll-loop formats
|
||||||
|
* slash commands like any other chat message.
|
||||||
|
*/
|
||||||
|
readonly supportsNativeSlashCommands: boolean;
|
||||||
|
|
||||||
/** Start a new query. Returns a handle for streaming input and output. */
|
/** Start a new query. Returns a handle for streaming input and output. */
|
||||||
query(input: QueryInput): AgentQuery;
|
query(input: QueryInput): AgentQuery;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True if the given error indicates the stored continuation is invalid
|
||||||
|
* (missing transcript, unknown session, etc.) and should be cleared.
|
||||||
|
*/
|
||||||
|
isSessionInvalid(err: unknown): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options passed to provider constructors. Fields are common to most
|
||||||
|
* providers; individual providers may ignore any they don't need.
|
||||||
|
*/
|
||||||
|
export interface ProviderOptions {
|
||||||
|
assistantName?: string;
|
||||||
|
mcpServers?: Record<string, McpServerConfig>;
|
||||||
|
env?: Record<string, string | undefined>;
|
||||||
|
additionalDirectories?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface QueryInput {
|
export interface QueryInput {
|
||||||
/** Initial prompt (already formatted by agent-runner). */
|
/** Initial prompt (already formatted by agent-runner). */
|
||||||
prompt: string;
|
prompt: string;
|
||||||
|
|
||||||
/** Session ID to resume, if any. */
|
/**
|
||||||
sessionId?: string;
|
* Opaque continuation token from a previous query. The provider decides
|
||||||
|
* what this means (session ID, thread ID, nothing at all).
|
||||||
/** Resume from a specific point in the session (provider-specific). */
|
*/
|
||||||
resumeAt?: string;
|
continuation?: string;
|
||||||
|
|
||||||
/** Working directory inside the container. */
|
/** Working directory inside the container. */
|
||||||
cwd: string;
|
cwd: string;
|
||||||
|
|
||||||
/** MCP server configurations. */
|
/**
|
||||||
mcpServers: Record<string, McpServerConfig>;
|
* System context to inject. Providers translate this into whatever their
|
||||||
|
* SDK expects (preset append, full system prompt, per-turn injection…).
|
||||||
/** System prompt / developer instructions. */
|
*/
|
||||||
systemPrompt?: string;
|
systemContext?: {
|
||||||
|
instructions?: string;
|
||||||
/** Environment variables for the SDK process. */
|
};
|
||||||
env: Record<string, string | undefined>;
|
|
||||||
|
|
||||||
/** Additional directories the agent can access. */
|
|
||||||
additionalDirectories?: string[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface McpServerConfig {
|
export interface McpServerConfig {
|
||||||
@@ -50,8 +70,13 @@ export interface AgentQuery {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type ProviderEvent =
|
export type ProviderEvent =
|
||||||
| { type: 'init'; sessionId: string }
|
| { type: 'init'; continuation: string }
|
||||||
| { type: 'result'; text: string | null }
|
| { type: 'result'; text: string | null }
|
||||||
| { type: 'error'; message: string; retryable: boolean; classification?: string }
|
| { type: 'error'; message: string; retryable: boolean; classification?: string }
|
||||||
| { type: 'progress'; message: string }
|
| { type: 'progress'; message: string }
|
||||||
|
/**
|
||||||
|
* Liveness signal. Providers MUST yield this on every underlying SDK
|
||||||
|
* event (tool call, thinking, partial message, anything) so the
|
||||||
|
* poll-loop's idle timer stays honest during long tool runs.
|
||||||
|
*/
|
||||||
| { type: 'activity' };
|
| { type: 'activity' };
|
||||||
|
|||||||
Reference in New Issue
Block a user