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
+126
View File
@@ -0,0 +1,126 @@
import type { MessageInRow } from './db/messages-in.js';
/**
* Routing context extracted from messages_in rows.
* Copied to messages_out by default so responses go back to the sender.
*/
export interface RoutingContext {
platformId: string | null;
channelType: string | null;
threadId: string | null;
inReplyTo: string | null;
}
/**
* Extract routing context from a batch of messages.
* Uses the first message's routing fields.
*/
export function extractRouting(messages: MessageInRow[]): RoutingContext {
const first = messages[0];
return {
platformId: first?.platform_id ?? null,
channelType: first?.channel_type ?? null,
threadId: first?.thread_id ?? null,
inReplyTo: first?.id ?? null,
};
}
/**
* Format a batch of messages_in rows into a prompt string.
* Strips routing fields — the agent never sees platform_id, channel_type, thread_id.
*/
export function formatMessages(messages: MessageInRow[]): string {
if (messages.length === 0) return '';
// Group by kind
const chatMessages = messages.filter((m) => m.kind === 'chat' || m.kind === 'chat-sdk');
const taskMessages = messages.filter((m) => m.kind === 'task');
const webhookMessages = messages.filter((m) => m.kind === 'webhook');
const systemMessages = messages.filter((m) => m.kind === 'system');
const parts: string[] = [];
if (chatMessages.length > 0) {
parts.push(formatChatMessages(chatMessages));
}
if (taskMessages.length > 0) {
parts.push(...taskMessages.map(formatTaskMessage));
}
if (webhookMessages.length > 0) {
parts.push(...webhookMessages.map(formatWebhookMessage));
}
if (systemMessages.length > 0) {
parts.push(...systemMessages.map(formatSystemMessage));
}
return parts.join('\n\n');
}
function formatChatMessages(messages: MessageInRow[]): string {
if (messages.length === 1) {
return formatSingleChat(messages[0]);
}
const lines = ['<messages>'];
for (const msg of messages) {
const content = parseContent(msg.content);
const sender = content.sender || content.author?.fullName || content.author?.userName || 'Unknown';
const time = formatTime(msg.timestamp);
const text = content.text || '';
lines.push(`<message sender="${escapeXml(sender)}" time="${time}">${escapeXml(text)}</message>`);
}
lines.push('</messages>');
return lines.join('\n');
}
function formatSingleChat(msg: MessageInRow): string {
const content = parseContent(msg.content);
const sender = content.sender || content.author?.fullName || content.author?.userName || 'Unknown';
const time = formatTime(msg.timestamp);
const text = content.text || '';
return `<message sender="${escapeXml(sender)}" time="${time}">${escapeXml(text)}</message>`;
}
function formatTaskMessage(msg: MessageInRow): string {
const content = parseContent(msg.content);
const parts = ['[SCHEDULED TASK]'];
if (content.scriptOutput) {
parts.push('', 'Script output:', JSON.stringify(content.scriptOutput, null, 2));
}
parts.push('', 'Instructions:', content.prompt || '');
return parts.join('\n');
}
function formatWebhookMessage(msg: MessageInRow): string {
const content = parseContent(msg.content);
const source = content.source || 'unknown';
const event = content.event || 'unknown';
return `[WEBHOOK: ${source}/${event}]\n\n${JSON.stringify(content.payload || content, null, 2)}`;
}
function formatSystemMessage(msg: MessageInRow): string {
const content = parseContent(msg.content);
return `[SYSTEM RESPONSE]\n\nAction: ${content.action || 'unknown'}\nStatus: ${content.status || 'unknown'}\nResult: ${JSON.stringify(content.result || null)}`;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function parseContent(json: string): any {
try {
return JSON.parse(json);
} catch {
return { text: json };
}
}
function formatTime(timestamp: string): string {
try {
const d = new Date(timestamp);
return `${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`;
} catch {
return timestamp;
}
}
function escapeXml(str: string): string {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
+96
View File
@@ -0,0 +1,96 @@
/**
* NanoClaw Agent Runner v2
*
* Runs inside a container. All IO goes through the session DB.
* No stdin, no stdout markers, no IPC files.
*
* Config:
* - SESSION_DB_PATH: path to session SQLite DB (default: /workspace/session.db)
* - AGENT_PROVIDER: 'claude' | 'mock' (default: claude)
* - NANOCLAW_ASSISTANT_NAME: assistant name for transcript archiving
* - NANOCLAW_ADMIN_USER_ID: admin user ID for permission checks
*
* Mount structure:
* /workspace/
* session.db ← session SQLite DB
* outbox/ ← outbound files
* agent/ ← agent group folder (CLAUDE.md, skills, working files)
* .claude/ ← Claude SDK session data
*/
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { createProvider, type ProviderName } from './providers/factory.js';
import { runPollLoop } from './poll-loop.js';
function log(msg: string): void {
console.error(`[agent-runner] ${msg}`);
}
const CWD = '/workspace/agent';
const GLOBAL_CLAUDE_MD = '/workspace/global/CLAUDE.md';
async function main(): Promise<void> {
const providerName = (process.env.AGENT_PROVIDER || 'claude') as ProviderName;
const assistantName = process.env.NANOCLAW_ASSISTANT_NAME;
log(`Starting v2 agent-runner (provider: ${providerName})`);
const provider = createProvider(providerName, { assistantName });
// Load global CLAUDE.md as additional system context
let systemPrompt: string | undefined;
if (fs.existsSync(GLOBAL_CLAUDE_MD)) {
systemPrompt = fs.readFileSync(GLOBAL_CLAUDE_MD, 'utf-8');
log('Loaded global CLAUDE.md');
}
// Discover additional directories mounted at /workspace/extra/*
const additionalDirectories: string[] = [];
const extraBase = '/workspace/extra';
if (fs.existsSync(extraBase)) {
for (const entry of fs.readdirSync(extraBase)) {
const fullPath = path.join(extraBase, entry);
if (fs.statSync(fullPath).isDirectory()) {
additionalDirectories.push(fullPath);
}
}
if (additionalDirectories.length > 0) {
log(`Additional directories: ${additionalDirectories.join(', ')}`);
}
}
// MCP server path
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const mcpServerPath = path.join(__dirname, 'mcp-tools.js');
// SDK env
const env: Record<string, string | undefined> = {
...process.env,
CLAUDE_CODE_AUTO_COMPACT_WINDOW: '165000',
};
await runPollLoop({
provider,
cwd: CWD,
mcpServers: {
nanoclaw: {
command: 'node',
args: [mcpServerPath],
env: {
SESSION_DB_PATH: process.env.SESSION_DB_PATH || '/workspace/session.db',
},
},
},
systemPrompt,
env,
additionalDirectories: additionalDirectories.length > 0 ? additionalDirectories : undefined,
});
}
main().catch((err) => {
log(`Fatal error: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
});
+81
View File
@@ -0,0 +1,81 @@
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
import { writeMessageOut } from './db/messages-out.js';
function log(msg: string): void {
console.error(`[mcp-tools] ${msg}`);
}
function generateId(): string {
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
/**
* Start the MCP server with NanoClaw tools.
* Reads the session DB path from SESSION_DB_PATH env var.
* Routing context is passed via env vars from the poll loop.
*/
export async function startMcpServer(): Promise<void> {
const server = new Server({ name: 'nanoclaw', version: '2.0.0' }, { capabilities: { tools: {} } });
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: 'send_message',
description: 'Send a chat message to the current conversation or a specified destination.',
inputSchema: {
type: 'object' as const,
properties: {
text: { type: 'string', description: 'Message content' },
channel: { type: 'string', description: 'Target channel type (default: reply to origin)' },
platformId: { type: 'string', description: 'Target platform ID' },
threadId: { type: 'string', description: 'Target thread ID' },
},
required: ['text'],
},
},
],
}));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
if (name === 'send_message') {
const text = args?.text as string;
if (!text) {
return { content: [{ type: 'text', text: 'Error: text is required' }] };
}
const id = generateId();
const platformId = (args?.platformId as string) || process.env.NANOCLAW_PLATFORM_ID || null;
const channelType = (args?.channel as string) || process.env.NANOCLAW_CHANNEL_TYPE || null;
const threadId = (args?.threadId as string) || process.env.NANOCLAW_THREAD_ID || null;
writeMessageOut({
id,
kind: 'chat',
platform_id: platformId,
channel_type: channelType,
thread_id: threadId,
content: JSON.stringify({ text }),
});
log(`send_message: ${id}${channelType || 'default'}/${platformId || 'default'}`);
return { content: [{ type: 'text', text: `Message sent (id: ${id})` }] };
}
return { content: [{ type: 'text', text: `Unknown tool: ${name}` }] };
});
const transport = new StdioServerTransport();
await server.connect(transport);
log('MCP server started');
}
// Run as standalone process
startMcpServer().catch((err) => {
log(`MCP server error: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
});
@@ -0,0 +1,210 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { initTestSessionDb, closeSessionDb, getSessionDb } from './db/connection.js';
import { getPendingMessages, markCompleted } from './db/messages-in.js';
import { getUndeliveredMessages } from './db/messages-out.js';
import { formatMessages, extractRouting } from './formatter.js';
import { MockProvider } from './providers/mock.js';
beforeEach(() => {
initTestSessionDb();
});
afterEach(() => {
closeSessionDb();
});
function insertMessage(id: string, kind: string, content: object, opts?: { processAfter?: string }) {
getSessionDb()
.prepare(
`INSERT INTO messages_in (id, kind, timestamp, status, process_after, content)
VALUES (?, ?, datetime('now'), 'pending', ?, ?)`,
)
.run(id, kind, opts?.processAfter ?? null, JSON.stringify(content));
}
describe('formatter', () => {
it('should format a single chat message', () => {
insertMessage('m1', 'chat', { sender: 'John', text: 'Hello world' });
const messages = getPendingMessages();
const prompt = formatMessages(messages);
expect(prompt).toContain('sender="John"');
expect(prompt).toContain('Hello world');
});
it('should format multiple chat messages as XML block', () => {
insertMessage('m1', 'chat', { sender: 'John', text: 'Hello' });
insertMessage('m2', 'chat', { sender: 'Jane', text: 'Hi there' });
const messages = getPendingMessages();
const prompt = formatMessages(messages);
expect(prompt).toContain('<messages>');
expect(prompt).toContain('</messages>');
expect(prompt).toContain('sender="John"');
expect(prompt).toContain('sender="Jane"');
});
it('should format task messages', () => {
insertMessage('m1', 'task', { prompt: 'Review open PRs' });
const messages = getPendingMessages();
const prompt = formatMessages(messages);
expect(prompt).toContain('[SCHEDULED TASK]');
expect(prompt).toContain('Review open PRs');
});
it('should format webhook messages', () => {
insertMessage('m1', 'webhook', { source: 'github', event: 'push', payload: { ref: 'main' } });
const messages = getPendingMessages();
const prompt = formatMessages(messages);
expect(prompt).toContain('[WEBHOOK: github/push]');
});
it('should format system messages', () => {
insertMessage('m1', 'system', { action: 'register_group', status: 'success', result: { id: 'ag-1' } });
const messages = getPendingMessages();
const prompt = formatMessages(messages);
expect(prompt).toContain('[SYSTEM RESPONSE]');
expect(prompt).toContain('register_group');
});
it('should handle mixed kinds', () => {
insertMessage('m1', 'chat', { sender: 'John', text: 'Hello' });
insertMessage('m2', 'system', { action: 'test', status: 'ok', result: null });
const messages = getPendingMessages();
const prompt = formatMessages(messages);
expect(prompt).toContain('sender="John"');
expect(prompt).toContain('[SYSTEM RESPONSE]');
});
it('should escape XML in content', () => {
insertMessage('m1', 'chat', { sender: 'A<B', text: 'x > y && z' });
const messages = getPendingMessages();
const prompt = formatMessages(messages);
expect(prompt).toContain('A&lt;B');
expect(prompt).toContain('x &gt; y &amp;&amp; z');
});
});
describe('routing', () => {
it('should extract routing from messages', () => {
getSessionDb()
.prepare(
`INSERT INTO messages_in (id, kind, timestamp, status, platform_id, channel_type, thread_id, content)
VALUES ('m1', 'chat', datetime('now'), 'pending', 'chan-123', 'discord', 'thread-456', '{"text":"hi"}')`,
)
.run();
const messages = getPendingMessages();
const routing = extractRouting(messages);
expect(routing.platformId).toBe('chan-123');
expect(routing.channelType).toBe('discord');
expect(routing.threadId).toBe('thread-456');
expect(routing.inReplyTo).toBe('m1');
});
});
describe('mock provider', () => {
it('should produce init + result events', async () => {
const provider = new MockProvider((prompt) => `Echo: ${prompt}`);
const query = provider.query({
prompt: 'Hello',
cwd: '/tmp',
mcpServers: {},
env: {},
});
const events: Array<{ type: string }> = [];
// End the stream after initial response
setTimeout(() => query.end(), 50);
for await (const event of query.events) {
events.push(event);
}
expect(events.length).toBeGreaterThanOrEqual(2);
expect(events[0].type).toBe('init');
expect(events[1].type).toBe('result');
expect((events[1] as { text: string }).text).toBe('Echo: Hello');
});
it('should handle push() during active query', async () => {
const provider = new MockProvider((prompt) => `Re: ${prompt}`);
const query = provider.query({
prompt: 'First',
cwd: '/tmp',
mcpServers: {},
env: {},
});
const events: Array<{ type: string; text?: string }> = [];
// Push a follow-up after a short delay, then end
setTimeout(() => query.push('Second'), 30);
setTimeout(() => query.end(), 60);
for await (const event of query.events) {
events.push(event);
}
const results = events.filter((e) => e.type === 'result');
expect(results).toHaveLength(2);
expect(results[0].text).toBe('Re: First');
expect(results[1].text).toBe('Re: Second');
});
});
describe('end-to-end with mock provider', () => {
it('should read messages_in, process with mock provider, write messages_out', async () => {
// Insert a chat message
insertMessage('m1', 'chat', { sender: 'User', text: 'What is 2+2?' });
// Read and process
const messages = getPendingMessages();
expect(messages).toHaveLength(1);
const routing = extractRouting(messages);
const prompt = formatMessages(messages);
// Create mock provider and run query
const provider = new MockProvider(() => 'The answer is 4');
const query = provider.query({
prompt,
cwd: '/tmp',
mcpServers: {},
env: {},
});
// Process events — simulate what poll-loop does
const { markProcessing } = await import('./db/messages-in.js');
const { writeMessageOut } = await import('./db/messages-out.js');
markProcessing(['m1']);
setTimeout(() => query.end(), 50);
for await (const event of query.events) {
if (event.type === 'result' && event.text) {
writeMessageOut({
id: `out-${Date.now()}`,
in_reply_to: routing.inReplyTo,
kind: 'chat',
platform_id: routing.platformId,
channel_type: routing.channelType,
thread_id: routing.threadId,
content: JSON.stringify({ text: event.text }),
});
}
}
markCompleted(['m1']);
// Verify: message was processed
const processed = getPendingMessages();
expect(processed).toHaveLength(0);
// Verify: response was written
const outMessages = getUndeliveredMessages();
expect(outMessages).toHaveLength(1);
expect(JSON.parse(outMessages[0].content).text).toBe('The answer is 4');
expect(outMessages[0].in_reply_to).toBe('m1');
});
});
+162
View File
@@ -0,0 +1,162 @@
import { getPendingMessages, markProcessing, markCompleted } from './db/messages-in.js';
import { writeMessageOut } from './db/messages-out.js';
import { formatMessages, extractRouting, type RoutingContext } from './formatter.js';
import type { AgentProvider, AgentQuery, McpServerConfig, ProviderEvent } from './providers/types.js';
const POLL_INTERVAL_MS = 1000;
const ACTIVE_POLL_INTERVAL_MS = 500;
function log(msg: string): void {
console.error(`[poll-loop] ${msg}`);
}
function generateId(): string {
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
export interface PollLoopConfig {
provider: AgentProvider;
cwd: string;
mcpServers: Record<string, McpServerConfig>;
systemPrompt?: string;
env: Record<string, string | undefined>;
additionalDirectories?: string[];
}
/**
* Main poll loop. Runs indefinitely until the process is killed.
*
* 1. Poll messages_in for pending rows
* 2. Format into prompt, call provider.query()
* 3. While query active: continue polling, push new messages via provider.push()
* 4. On result: write messages_out
* 5. Mark messages completed
* 6. Loop
*/
export async function runPollLoop(config: PollLoopConfig): Promise<void> {
let sessionId: string | undefined;
let resumeAt: string | undefined;
while (true) {
const messages = getPendingMessages();
if (messages.length === 0) {
await sleep(POLL_INTERVAL_MS);
continue;
}
const ids = messages.map((m) => m.id);
markProcessing(ids);
const routing = extractRouting(messages);
const prompt = formatMessages(messages);
log(`Processing ${messages.length} message(s), kinds: ${[...new Set(messages.map((m) => m.kind))].join(',')}`);
// Set routing context as env vars for MCP tools
setRoutingEnv(routing, config.env);
const query = config.provider.query({
prompt,
sessionId,
resumeAt,
cwd: config.cwd,
mcpServers: config.mcpServers,
systemPrompt: config.systemPrompt,
env: config.env,
additionalDirectories: config.additionalDirectories,
});
// Process the query while concurrently polling for new messages
const result = await processQuery(query, routing, config);
if (result.sessionId) sessionId = result.sessionId;
if (result.resumeAt) resumeAt = result.resumeAt;
markCompleted(ids);
log(`Completed ${ids.length} message(s)`);
}
}
interface QueryResult {
sessionId?: string;
resumeAt?: string;
}
async function processQuery(query: AgentQuery, routing: RoutingContext, config: PollLoopConfig): Promise<QueryResult> {
let querySessionId: string | undefined;
let done = false;
// Concurrent polling: push new messages into the active query
const pollHandle = setInterval(() => {
if (done) return;
const newMessages = getPendingMessages();
if (newMessages.length === 0) return;
const newIds = newMessages.map((m) => m.id);
markProcessing(newIds);
const prompt = formatMessages(newMessages);
log(`Pushing ${newMessages.length} follow-up message(s) into active query`);
query.push(prompt);
// Update routing env for MCP tools with latest message context
const newRouting = extractRouting(newMessages);
setRoutingEnv(newRouting, config.env);
// Mark these completed immediately (they've been pushed to the provider)
markCompleted(newIds);
}, ACTIVE_POLL_INTERVAL_MS);
try {
for await (const event of query.events) {
handleEvent(event, routing);
if (event.type === 'init') {
querySessionId = event.sessionId;
} else if (event.type === 'result' && event.text) {
writeMessageOut({
id: generateId(),
in_reply_to: routing.inReplyTo,
kind: routing.channelType ? 'chat' : 'chat',
platform_id: routing.platformId,
channel_type: routing.channelType,
thread_id: routing.threadId,
content: JSON.stringify({ text: event.text }),
});
}
}
} finally {
done = true;
clearInterval(pollHandle);
}
return { sessionId: querySessionId };
}
function handleEvent(event: ProviderEvent, _routing: RoutingContext): void {
switch (event.type) {
case 'init':
log(`Session: ${event.sessionId}`);
break;
case 'result':
log(`Result: ${event.text ? event.text.slice(0, 200) : '(empty)'}`);
break;
case 'error':
log(`Error: ${event.message} (retryable: ${event.retryable}${event.classification ? `, ${event.classification}` : ''})`);
break;
case 'progress':
log(`Progress: ${event.message}`);
break;
}
}
function setRoutingEnv(routing: RoutingContext, env: Record<string, string | undefined>): void {
env.NANOCLAW_PLATFORM_ID = routing.platformId ?? undefined;
env.NANOCLAW_CHANNEL_TYPE = routing.channelType ?? undefined;
env.NANOCLAW_THREAD_ID = routing.threadId ?? undefined;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
@@ -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();
},
};
}
}
@@ -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}`);
}
}
@@ -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?.();
},
};
}
}
@@ -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 };
+1 -1
View File
@@ -2,6 +2,6 @@ import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['src/**/*.test.ts', 'setup/**/*.test.ts'],
include: ['src/**/*.test.ts', 'setup/**/*.test.ts', 'container/agent-runner/src/**/*.test.ts'],
},
});