runtime: split ipc queue and runner helpers
This commit is contained in:
174
runners/agent-runner/src/output-protocol.ts
Normal file
174
runners/agent-runner/src/output-protocol.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import {
|
||||
extractImageTagPaths,
|
||||
normalizePublicTextOutput,
|
||||
type RunnerStructuredOutput,
|
||||
writeProtocolOutput,
|
||||
} from 'ejclaw-runners-shared';
|
||||
|
||||
export interface RunnerOutput {
|
||||
status: 'success' | 'error';
|
||||
phase?: 'progress' | 'final' | 'tool-activity' | 'intermediate';
|
||||
agentId?: string;
|
||||
agentLabel?: string;
|
||||
agentDone?: boolean;
|
||||
result: string | null;
|
||||
output?: RunnerStructuredOutput;
|
||||
newSessionId?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
type ContentBlock =
|
||||
| { type: 'text'; text: string }
|
||||
| {
|
||||
type: 'image';
|
||||
source: {
|
||||
type: 'base64';
|
||||
media_type: 'image/jpeg' | 'image/png' | 'image/gif' | 'image/webp';
|
||||
data: string;
|
||||
};
|
||||
};
|
||||
|
||||
interface SDKUserMessage {
|
||||
type: 'user';
|
||||
message: { role: 'user'; content: string | ContentBlock[] };
|
||||
parent_tool_use_id: null;
|
||||
session_id: string;
|
||||
}
|
||||
|
||||
interface AssistantContentBlock {
|
||||
type?: string;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export type LogFn = (message: string) => void;
|
||||
type StreamContent = string | ContentBlock[];
|
||||
|
||||
const MIME_TYPES: Record<string, string> = {
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.png': 'image/png',
|
||||
'.gif': 'image/gif',
|
||||
'.webp': 'image/webp',
|
||||
};
|
||||
|
||||
export function buildMultimodalContent(
|
||||
text: string,
|
||||
log: LogFn,
|
||||
): StreamContent {
|
||||
const { cleanText, imagePaths } = extractImageTagPaths(text);
|
||||
if (imagePaths.length === 0) return text;
|
||||
|
||||
const blocks: ContentBlock[] = [];
|
||||
if (cleanText) {
|
||||
blocks.push({ type: 'text', text: cleanText });
|
||||
}
|
||||
|
||||
for (const imgPath of imagePaths) {
|
||||
try {
|
||||
if (!fs.existsSync(imgPath)) {
|
||||
log(`Image not found, skipping: ${imgPath}`);
|
||||
continue;
|
||||
}
|
||||
const data = fs.readFileSync(imgPath).toString('base64');
|
||||
const ext = path.extname(imgPath).toLowerCase();
|
||||
const mediaType = (MIME_TYPES[ext] || 'image/png') as
|
||||
| 'image/jpeg'
|
||||
| 'image/png'
|
||||
| 'image/gif'
|
||||
| 'image/webp';
|
||||
blocks.push({
|
||||
type: 'image',
|
||||
source: { type: 'base64', media_type: mediaType, data },
|
||||
});
|
||||
log(`Added image block: ${imgPath} (${mediaType})`);
|
||||
} catch (err) {
|
||||
log(
|
||||
`Failed to read image ${imgPath}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return blocks.length > 0 ? blocks : text;
|
||||
}
|
||||
|
||||
export class MessageStream {
|
||||
private queue: SDKUserMessage[] = [];
|
||||
private waiting: (() => void) | null = null;
|
||||
private done = false;
|
||||
|
||||
constructor(private readonly buildContent: (text: string) => StreamContent) {}
|
||||
|
||||
push(text: string): void {
|
||||
const content = this.buildContent(text);
|
||||
this.queue.push({
|
||||
type: 'user',
|
||||
message: { role: 'user', content },
|
||||
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>((resolve) => {
|
||||
this.waiting = resolve;
|
||||
});
|
||||
this.waiting = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function readStdin(): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let data = '';
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
process.stdin.on('end', () => resolve(data));
|
||||
process.stdin.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
export function writeOutput(output: RunnerOutput): void {
|
||||
writeProtocolOutput(output);
|
||||
}
|
||||
|
||||
export function normalizeStructuredOutput(result: string | null): {
|
||||
result: string | null;
|
||||
output?: RunnerOutput['output'];
|
||||
} {
|
||||
return normalizePublicTextOutput(result);
|
||||
}
|
||||
|
||||
export function extractAssistantText(message: unknown): string | null {
|
||||
const assistant = message as {
|
||||
message?: {
|
||||
content?: AssistantContentBlock[];
|
||||
};
|
||||
};
|
||||
const blocks = assistant.message?.content;
|
||||
if (!Array.isArray(blocks)) return null;
|
||||
|
||||
const text = blocks
|
||||
.filter((block) => block?.type === 'text' && typeof block.text === 'string')
|
||||
.map((block) => block.text!.trim())
|
||||
.filter(Boolean)
|
||||
.join('\n\n')
|
||||
.trim();
|
||||
|
||||
return text || null;
|
||||
}
|
||||
Reference in New Issue
Block a user