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:
@@ -3,7 +3,7 @@ import path from 'path';
|
||||
|
||||
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 {
|
||||
console.error(`[claude-provider] ${msg}`);
|
||||
@@ -161,31 +161,61 @@ function createPreCompactHook(assistantName?: string): HookCallback {
|
||||
|
||||
// ── 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 {
|
||||
const stream = new MessageStream();
|
||||
stream.push(input.prompt);
|
||||
|
||||
const instructions = input.systemContext?.instructions;
|
||||
|
||||
const sdkResult = sdkQuery({
|
||||
prompt: stream,
|
||||
options: {
|
||||
cwd: input.cwd,
|
||||
additionalDirectories: input.additionalDirectories,
|
||||
resume: input.sessionId,
|
||||
resumeSessionAt: input.resumeAt,
|
||||
systemPrompt: input.systemPrompt ? { type: 'preset' as const, preset: 'claude_code' as const, append: input.systemPrompt } : undefined,
|
||||
additionalDirectories: this.additionalDirectories,
|
||||
resume: input.continuation,
|
||||
systemPrompt: instructions ? { type: 'preset' as const, preset: 'claude_code' as const, append: instructions } : undefined,
|
||||
allowedTools: TOOL_ALLOWLIST,
|
||||
env: input.env,
|
||||
env: this.env,
|
||||
permissionMode: 'bypassPermissions',
|
||||
allowDangerouslySkipPermissions: true,
|
||||
settingSources: ['project', 'user'],
|
||||
mcpServers: input.mcpServers,
|
||||
mcpServers: this.mcpServers,
|
||||
hooks: {
|
||||
PreCompact: [{ hooks: [createPreCompactHook(this.assistantName)] }],
|
||||
},
|
||||
@@ -204,7 +234,7 @@ export class ClaudeProvider implements AgentProvider {
|
||||
yield { type: 'activity' };
|
||||
|
||||
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') {
|
||||
const text = 'result' in message ? (message as { result?: string }).result ?? null : null;
|
||||
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 { MockProvider } from './mock.js';
|
||||
|
||||
export type ProviderName = 'claude' | 'mock';
|
||||
|
||||
export function createProvider(name: ProviderName, opts?: { assistantName?: string }): AgentProvider {
|
||||
export function createProvider(name: ProviderName, options: ProviderOptions = {}): AgentProvider {
|
||||
switch (name) {
|
||||
case 'claude':
|
||||
return new ClaudeProvider(opts);
|
||||
return new ClaudeProvider(options);
|
||||
case 'mock':
|
||||
return new MockProvider();
|
||||
return new MockProvider(options);
|
||||
default:
|
||||
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.
|
||||
* Supports push() — queued messages produce additional results.
|
||||
*/
|
||||
export class MockProvider implements AgentProvider {
|
||||
readonly supportsNativeSlashCommands = false;
|
||||
|
||||
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)}`);
|
||||
}
|
||||
|
||||
isSessionInvalid(_err: unknown): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
query(input: QueryInput): AgentQuery {
|
||||
const pending: string[] = [];
|
||||
let waiting: (() => void) | null = null;
|
||||
@@ -21,7 +27,7 @@ export class MockProvider implements AgentProvider {
|
||||
const events: AsyncIterable<ProviderEvent> = {
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield { type: 'activity' };
|
||||
yield { type: 'init', sessionId: `mock-session-${Date.now()}` };
|
||||
yield { type: 'init', continuation: `mock-session-${Date.now()}` };
|
||||
|
||||
// Process initial prompt
|
||||
yield { type: 'activity' };
|
||||
|
||||
@@ -1,32 +1,52 @@
|
||||
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. */
|
||||
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 {
|
||||
/** Initial prompt (already formatted by agent-runner). */
|
||||
prompt: string;
|
||||
|
||||
/** Session ID to resume, if any. */
|
||||
sessionId?: string;
|
||||
|
||||
/** Resume from a specific point in the session (provider-specific). */
|
||||
resumeAt?: string;
|
||||
/**
|
||||
* Opaque continuation token from a previous query. The provider decides
|
||||
* what this means (session ID, thread ID, nothing at all).
|
||||
*/
|
||||
continuation?: string;
|
||||
|
||||
/** Working directory inside the container. */
|
||||
cwd: string;
|
||||
|
||||
/** MCP server configurations. */
|
||||
mcpServers: Record<string, McpServerConfig>;
|
||||
|
||||
/** System prompt / developer instructions. */
|
||||
systemPrompt?: string;
|
||||
|
||||
/** Environment variables for the SDK process. */
|
||||
env: Record<string, string | undefined>;
|
||||
|
||||
/** Additional directories the agent can access. */
|
||||
additionalDirectories?: string[];
|
||||
/**
|
||||
* System context to inject. Providers translate this into whatever their
|
||||
* SDK expects (preset append, full system prompt, per-turn injection…).
|
||||
*/
|
||||
systemContext?: {
|
||||
instructions?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface McpServerConfig {
|
||||
@@ -50,8 +70,13 @@ export interface AgentQuery {
|
||||
}
|
||||
|
||||
export type ProviderEvent =
|
||||
| { type: 'init'; sessionId: string }
|
||||
| { type: 'init'; continuation: string }
|
||||
| { type: 'result'; text: string | null }
|
||||
| { type: 'error'; message: string; retryable: boolean; classification?: 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' };
|
||||
|
||||
Reference in New Issue
Block a user