v2 phase 2: agent-runner — provider interface, poll loop, formatter

AgentProvider abstraction with Claude and Mock implementations.
Poll loop reads messages_in, formats by kind, queries provider,
writes results to messages_out. Concurrent polling pushes follow-up
messages into active queries.

- providers/types.ts: AgentProvider, AgentQuery, ProviderEvent
- providers/claude.ts: wraps Agent SDK with MessageStream, hooks,
  transcript archiving
- providers/mock.ts: canned responses with push() support
- providers/factory.ts: createProvider()
- formatter.ts: format by kind (chat/task/webhook/system), XML
  escaping, routing extraction
- poll-loop.ts: poll → format → query → write, concurrent polling
- mcp-tools.ts: MCP server with send_message tool
- index-v2.ts: new entry point (config from env, enters poll loop)
- 11 new tests, all 288 tests pass

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
gavrielc
2026-04-08 23:36:55 +03:00
parent 3f0451b7b0
commit 5a0098edc9
10 changed files with 1045 additions and 1 deletions

View File

@@ -0,0 +1,231 @@
import fs from 'fs';
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';
function log(msg: string): void {
console.error(`[claude-provider] ${msg}`);
}
// Tool allowlist for NanoClaw agent containers
const TOOL_ALLOWLIST = [
'Bash',
'Read',
'Write',
'Edit',
'Glob',
'Grep',
'WebSearch',
'WebFetch',
'Task',
'TaskOutput',
'TaskStop',
'TeamCreate',
'TeamDelete',
'SendMessage',
'TodoWrite',
'ToolSearch',
'Skill',
'NotebookEdit',
'mcp__nanoclaw__*',
];
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');
}
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 ──
export class ClaudeProvider implements AgentProvider {
private assistantName?: string;
constructor(opts?: { assistantName?: string }) {
this.assistantName = opts?.assistantName;
}
query(input: QueryInput): AgentQuery {
const stream = new MessageStream();
stream.push(input.prompt);
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,
allowedTools: TOOL_ALLOWLIST,
env: input.env,
permissionMode: 'bypassPermissions',
allowDangerouslySkipPermissions: true,
settingSources: ['project', 'user'],
mcpServers: input.mcpServers,
hooks: {
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++;
if (message.type === 'system' && message.subtype === 'init') {
yield { type: 'init', sessionId: 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 === 'task_notification') {
const tn = message as { summary?: string };
yield { type: 'progress', message: tn.summary || 'Task notification' };
}
// All other message types are logged but not emitted
}
log(`Query completed after ${messageCount} SDK messages`);
}
return {
push: (msg) => stream.push(msg),
end: () => stream.end(),
events: translateEvents(),
abort: () => {
aborted = true;
stream.end();
},
};
}
}

View File

@@ -0,0 +1,16 @@
import type { AgentProvider } 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 {
switch (name) {
case 'claude':
return new ClaudeProvider(opts);
case 'mock':
return new MockProvider();
default:
throw new Error(`Unknown provider: ${name}`);
}
}

View File

@@ -0,0 +1,66 @@
import type { AgentProvider, AgentQuery, ProviderEvent, QueryInput } from './types.js';
/**
* Mock provider for testing. Returns canned responses.
* Supports push() — queued messages produce additional results.
*/
export class MockProvider implements AgentProvider {
private responseFactory: (prompt: string) => string;
constructor(responseFactory?: (prompt: string) => string) {
this.responseFactory = responseFactory ?? ((prompt) => `Mock response to: ${prompt.slice(0, 100)}`);
}
query(input: QueryInput): AgentQuery {
const pending: string[] = [];
let waiting: (() => void) | null = null;
let ended = false;
let aborted = false;
const responseFactory = this.responseFactory;
const events: AsyncIterable<ProviderEvent> = {
async *[Symbol.asyncIterator]() {
yield { type: 'init', sessionId: `mock-session-${Date.now()}` };
// Process initial prompt
yield { type: 'result', text: responseFactory(input.prompt) };
// Process any pushed follow-ups
while (!ended && !aborted) {
if (pending.length > 0) {
const msg = pending.shift()!;
yield { type: 'result', text: responseFactory(msg) };
continue;
}
// Wait for push() or end()
await new Promise<void>((resolve) => {
waiting = resolve;
});
waiting = null;
}
// Drain remaining
while (pending.length > 0) {
const msg = pending.shift()!;
yield { type: 'result', text: responseFactory(msg) };
}
},
};
return {
push(message: string) {
pending.push(message);
waiting?.();
},
end() {
ended = true;
waiting?.();
},
events,
abort() {
aborted = true;
waiting?.();
},
};
}
}

View File

@@ -0,0 +1,56 @@
export interface AgentProvider {
/** Start a new query. Returns a handle for streaming input and output. */
query(input: QueryInput): AgentQuery;
}
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;
/** 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[];
}
export interface McpServerConfig {
command: string;
args: string[];
env: Record<string, string>;
}
export interface AgentQuery {
/** Push a follow-up message into the active query. */
push(message: string): void;
/** Signal that no more input will be sent. */
end(): void;
/** Output event stream. */
events: AsyncIterable<ProviderEvent>;
/** Force-stop the query. */
abort(): void;
}
export type ProviderEvent =
| { type: 'init'; sessionId: string }
| { type: 'result'; text: string | null }
| { type: 'error'; message: string; retryable: boolean; classification?: string }
| { type: 'progress'; message: string };