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