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:
gavrielc
2026-04-13 10:25:29 +03:00
parent e07158e194
commit b63dd186df
8 changed files with 156 additions and 114 deletions

View File

@@ -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,
}); });
} }

View File

@@ -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')));

View File

@@ -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

View File

@@ -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)'}`);

View File

@@ -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 };

View File

@@ -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}`);
} }

View File

@@ -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' };

View File

@@ -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' };