← vibesdk
worker/agents/core/conversation/MessageLoader.ts
7.3 KB
Download file
/**
* Conversation message loader abstraction.
*
* Different code-generation behaviors persist chat history in different
* backends:
* - `phasic` / `agentic` → the agent's SQLite-backed `full_conversations`
* table (managed by `CodeGeneratorAgent`).
* - `think` → the `ThinkAgent` DO's message store (AI-SDK `UIMessage[]`),
* written natively by Think as it streams responses.
*
* `ConversationMessageLoader` lets the WebSocket boundary load and
* mutate conversation history without knowing which backend is active.
*/
import type { ConversationMessage, ConversationState, ToolCall } from '../../inferutils/common';
import type { UIMessage } from 'ai';
export abstract class ConversationMessageLoader {
abstract load(): Promise<ConversationState>;
abstract append(message: ConversationMessage): Promise<void>;
abstract clear(): Promise<void>;
}
// ── Local (VibeSDK SQLite) implementation ───────────────────────────
/**
* Minimal surface the local loader needs from the host agent. Avoids a
* hard import of `CodeGeneratorAgent` so this file stays cycle-free.
*/
export interface LocalConversationBackend {
getConversationState(id?: string): ConversationState;
setConversationState(state: ConversationState): void;
addConversationMessage(message: ConversationMessage): void;
clearConversation(): void;
}
export class LocalConversationMessageLoader extends ConversationMessageLoader {
constructor(private readonly backend: LocalConversationBackend) {
super();
}
async load(): Promise<ConversationState> {
return this.backend.getConversationState();
}
async append(message: ConversationMessage): Promise<void> {
this.backend.addConversationMessage(message);
}
async clear(): Promise<void> {
this.backend.clearConversation();
}
}
// ── ThinkAgent (think) implementation ───────────────────────────────
interface ThinkAgentLike {
getMessages(): Promise<UIMessage[]> | UIMessage[];
clearMessages(): Promise<void> | void;
}
/**
* Reads chat history from `ThinkAgent.getMessages()` and expands each AI-SDK
* `UIMessage` into one or more VibeSDK `ConversationMessage` entries, matching
* the OpenAI-canonical shape the chat UI's reload hydration expects:
*
* { role: 'assistant', content: <text>, tool_calls: [...] }
* { role: 'tool', name, tool_call_id, content: <output> }
*
* Writes are no-ops: Think persists every prompt and assistant reply itself.
*/
export class ThinkMessageLoader extends ConversationMessageLoader {
// The stub is resolved lazily (via `getAgentByName`, supplied by the host)
// so the agents framework `_init` handshake runs before any RPC — a raw
// `ns.get(idFromName())` stub leaves the message store undefined.
constructor(private readonly resolveStub: () => Promise<ThinkAgentLike>) {
super();
}
async load(): Promise<ConversationState> {
let messages: UIMessage[] = [];
try {
const stub = await this.resolveStub();
messages = await stub.getMessages();
} catch {
messages = [];
}
const history = messages.flatMap((m) => uiMessageToConversations(m));
return { id: 'default', runningHistory: history, fullHistory: history };
}
async append(_message: ConversationMessage): Promise<void> {
// no-op: ThinkAgent persists messages as part of its turn pipeline.
}
async clear(): Promise<void> {
try {
const stub = await this.resolveStub();
await stub.clearMessages();
} catch {
// best-effort
}
}
}
// ── Translation helpers ──────────────────────────────────────────────
type AnyUIPart = UIMessage['parts'][number];
function partText(part: AnyUIPart): string {
return part.type === 'text' && typeof (part as { text?: string }).text === 'string'
? (part as { text: string }).text
: '';
}
function collectText(message: UIMessage): string {
return message.parts
.map(partText)
.filter((t) => t.length > 0)
.join('\n')
.trim();
}
/** AI-SDK tool parts are `tool-<name>` (static) or `dynamic-tool`. */
function isToolUIPart(part: AnyUIPart): boolean {
return typeof part.type === 'string' && (part.type.startsWith('tool-') || part.type === 'dynamic-tool');
}
function isReasoningUIPart(part: AnyUIPart): boolean {
return part.type === 'reasoning';
}
function toolNameOf(part: AnyUIPart): string {
if (part.type === 'dynamic-tool') return (part as { toolName?: string }).toolName ?? 'tool';
return part.type.startsWith('tool-') ? part.type.slice('tool-'.length) : 'tool';
}
function toolReplyContent(part: AnyUIPart): string | null {
const p = part as { state?: string; output?: unknown; errorText?: string };
if (p.state === 'output-available') {
return typeof p.output === 'string' ? p.output : safeStringifyInput((p.output as Record<string, unknown>) ?? {});
}
if (p.state === 'output-error') {
return p.errorText ?? '';
}
return null;
}
/**
* Expand an AI-SDK `UIMessage` into ordered VibeSDK `ConversationMessage`
* entries, preserving the true text ↔ reasoning ↔ tool interleaving so the
* chat UI reconstructs the same sequence on reload as it showed live.
*
* Each assistant text/reasoning/tool part is emitted as its own message in
* order; contiguous parts are merged back into one bubble by the frontend.
*/
function uiMessageToConversations(message: UIMessage): ConversationMessage[] {
if (message.role === 'user') {
const text = collectText(message);
return text ? [{ role: 'user', content: text, conversationId: message.id }] : [];
}
if (message.role !== 'assistant') return [];
const out: ConversationMessage[] = [];
let partIndex = 0;
for (const part of message.parts) {
if (isReasoningUIPart(part)) {
const reasoning = (part as { text?: string }).text ?? '';
if (reasoning.trim().length > 0) {
out.push({
role: 'assistant',
content: '',
reasoning,
conversationId: `${message.id}:r${partIndex}`,
});
}
} else if (isToolUIPart(part)) {
const p = part as { toolCallId?: string; input?: unknown };
if (!p.toolCallId) { partIndex++; continue; }
const name = toolNameOf(part);
const toolCall: ToolCall = {
id: p.toolCallId,
type: 'function',
function: {
name,
arguments: safeStringifyInput((p.input as Record<string, unknown>) ?? {}),
},
};
out.push({
role: 'assistant',
content: '',
tool_calls: [toolCall],
conversationId: `${message.id}:a${partIndex}`,
});
const replyContent = toolReplyContent(part);
if (replyContent !== null) {
out.push({
role: 'tool',
name,
tool_call_id: p.toolCallId,
content: replyContent,
conversationId: `${message.id}:${p.toolCallId}`,
});
}
} else {
const text = partText(part);
if (text.trim().length > 0) {
out.push({
role: 'assistant',
content: text,
conversationId: `${message.id}:t${partIndex}`,
});
}
}
partIndex++;
}
return out;
}
/** Defensive `JSON.stringify` — never blow up reload on a serialization error. */
function safeStringifyInput(input: Record<string, unknown>): string {
try {
return JSON.stringify(input);
} catch {
return '{}';
}
}