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:
231
container/agent-runner/src/providers/claude.ts
Normal file
231
container/agent-runner/src/providers/claude.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
16
container/agent-runner/src/providers/factory.ts
Normal file
16
container/agent-runner/src/providers/factory.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
66
container/agent-runner/src/providers/mock.ts
Normal file
66
container/agent-runner/src/providers/mock.ts
Normal 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?.();
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
56
container/agent-runner/src/providers/types.ts
Normal file
56
container/agent-runner/src/providers/types.ts
Normal 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 };
|
||||
Reference in New Issue
Block a user