import { randomUUID } from "node:crypto"; import { EventEmitter } from "node:events"; import type { AgentCapability, PlanningQuestion } from "@fusion/core"; import { resolvePrompt, type PromptOverrideMap } from "@fusion/core"; import { createFnAgent as engineCreateFnAgent } from "@fusion/engine"; import { SessionEventBuffer, type SessionBufferedEvent } from "./sse-buffer.js"; export interface AgentOnboardingSummary { name: string; role: AgentCapability | "custom"; instructionsText: string; thinkingLevel: "off" | "minimal" | "low" | "medium" | "high"; maxTurns: number; title?: string; icon?: string; reportsTo?: string; soul?: string; memory?: string; skills?: string[]; templateId?: string; patternAgentId?: string; rationale?: string; } export type AgentOnboardingStreamEvent = | { type: "thinking"; data: string } | { type: "question"; data: PlanningQuestion } | { type: "summary"; data: AgentOnboardingSummary } | { type: "error"; data: string } | { type: "complete" }; export type AgentOnboardingStreamCallback = (event: AgentOnboardingStreamEvent, eventId?: number) => void; const createFnAgent: any = engineCreateFnAgent; const SESSION_TTL_MS = 30 * 60 * 1000; const CLEANUP_INTERVAL_MS = 5 * 60 * 1000; const GENERATION_TIMEOUT_MS = 120_000; export const AGENT_ONBOARDING_SYSTEM_PROMPT = `You are an agent onboarding assistant for the fn task board system. Your job is to guide users through creating a new agent with a short interview. Use the provided context (existing agents + template options) to make concrete suggestions. Ask targeted questions using this JSON format: {"type":"question","data":{"id":"q1","type":"text|single_select|multi_select|confirm","question":"...","description":"...","options":[{"id":"x","label":"X","description":"..."}]}} When ready, return a final summary JSON in this exact format: {"type":"complete","data":{"name":"...","role":"executor","instructionsText":"...","thinkingLevel":"medium","maxTurns":25,"title":"...","icon":"🤖","reportsTo":"...","soul":"...","memory":"...","skills":["..."],"templateId":"...","patternAgentId":"...","rationale":"..."}} Rules: - role must be one of triage|executor|reviewer|merger|scheduler|engineer|custom - thinkingLevel must be off|minimal|low|medium|high - maxTurns must be a positive integer - Do not include runtimeMode/model/runtimeHint; those are user review-time choices.`; interface Session { id: string; ip: string; contextPrompt: string; currentQuestion?: PlanningQuestion; summary?: AgentOnboardingSummary; error?: string; history: Array<{ question: PlanningQuestion; response: Record }>; thinkingOutput: string; agent?: any; createdAt: Date; updatedAt: Date; } const sessions = new Map(); const activeGenerations = new Map(); export class AgentOnboardingStreamManager extends EventEmitter { private readonly sessions = new Map>(); private readonly buffers = new Map(); subscribe(sessionId: string, callback: AgentOnboardingStreamCallback): () => void { if (!this.sessions.has(sessionId)) this.sessions.set(sessionId, new Set()); const callbacks = this.sessions.get(sessionId)!; callbacks.add(callback); return () => { callbacks.delete(callback); if (callbacks.size === 0) this.sessions.delete(sessionId); }; } private getBuffer(sessionId: string): SessionEventBuffer { let buffer = this.buffers.get(sessionId); if (!buffer) { buffer = new SessionEventBuffer(100); this.buffers.set(sessionId, buffer); } return buffer; } broadcast(sessionId: string, event: AgentOnboardingStreamEvent): number { const serialized = JSON.stringify((event as { data?: unknown }).data ?? {}); const eventId = this.getBuffer(sessionId).push(event.type, serialized); const callbacks = this.sessions.get(sessionId); if (!callbacks) return eventId; for (const callback of callbacks) callback(event, eventId); return eventId; } getBufferedEvents(sessionId: string, sinceId: number): SessionBufferedEvent[] { const buffer = this.buffers.get(sessionId); if (!buffer) return []; return buffer.getEventsSince(sinceId); } cleanupSession(sessionId: string): void { this.sessions.delete(sessionId); this.buffers.delete(sessionId); } } export const agentOnboardingStreamManager = new AgentOnboardingStreamManager(); function extractJsonCandidate(text: string): string | null { const codeBlockMatch = text.match(/```(?:json)?\s*([\s\S]*?)\s*```/); if (codeBlockMatch?.[1]) return codeBlockMatch[1].trim(); const first = text.indexOf("{"); const last = text.lastIndexOf("}"); if (first >= 0 && last > first) return text.slice(first, last + 1); return text.trim().startsWith("{") ? text.trim() : null; } function repairJson(text: string): string { return text.replace(/,\s*([}\]])/g, "$1"); } export function parseAgentOnboardingResponse(text: string): { type: "question"; data: PlanningQuestion } | { type: "complete"; data: AgentOnboardingSummary } { const candidate = extractJsonCandidate(text); if (!candidate) throw new Error("AI returned no valid JSON"); let parsed: any; try { parsed = JSON.parse(candidate); } catch { parsed = JSON.parse(repairJson(candidate)); } if (!parsed || (parsed.type !== "question" && parsed.type !== "complete")) { throw new Error("AI returned invalid response type"); } if (parsed.type === "complete") { const data = parsed.data ?? {}; if (typeof data.name !== "string" || !data.name.trim()) throw new Error("Invalid summary.name"); if (typeof data.instructionsText !== "string" || !data.instructionsText.trim()) throw new Error("Invalid summary.instructionsText"); if (!Number.isInteger(data.maxTurns) || data.maxTurns <= 0) throw new Error("Invalid summary.maxTurns"); } return parsed; } export function createAgentOnboardingSessionPrompt(input: { intent: string; existingAgents: Array<{ id: string; name: string; role: string }>; templates: Array<{ id: string; label: string; description?: string }>; }): string { const compactAgents = input.existingAgents.slice(0, 25).map((a) => `${a.id}:${a.name}(${a.role})`).join("\n") || "none"; const compactTemplates = input.templates.slice(0, 25).map((t) => `${t.id}:${t.label}${t.description ? ` - ${t.description}` : ""}`).join("\n") || "none"; return `User intent:\n${input.intent}\n\nExisting agents:\n${compactAgents}\n\nTemplate/preset options:\n${compactTemplates}`; } export async function startAgentOnboardingSession( ip: string, initialContext: { intent: string; existingAgents: Array<{ id: string; name: string; role: string }>; templates: Array<{ id: string; label: string; description?: string }> }, rootDir: string, modelProvider?: string, modelId?: string, promptOverrides?: PromptOverrideMap, ): Promise { const id = randomUUID(); const session: Session = { id, ip, contextPrompt: createAgentOnboardingSessionPrompt(initialContext), history: [], thinkingOutput: "", createdAt: new Date(), updatedAt: new Date(), }; sessions.set(id, session); const systemPrompt = resolvePrompt("agent-onboarding-system", promptOverrides) || AGENT_ONBOARDING_SYSTEM_PROMPT; session.agent = await createFnAgent({ cwd: rootDir, systemPrompt, tools: "readonly", ...(modelProvider && modelId ? { defaultProvider: modelProvider, defaultModelId: modelId } : {}), onThinking: (delta: string) => { session.thinkingOutput += delta; agentOnboardingStreamManager.broadcast(session.id, { type: "thinking", data: delta }); }, onText: (delta: string) => { session.thinkingOutput += delta; agentOnboardingStreamManager.broadcast(session.id, { type: "thinking", data: delta }); }, }); void continueConversation(session, session.contextPrompt); return id; } async function runGenerationWithTimeout(session: Session, operation: () => Promise): Promise { const existing = activeGenerations.get(session.id); if (existing) { clearTimeout(existing.timer); existing.abortController.abort(); } const abortController = new AbortController(); const timer = setTimeout(() => { session.error = "AI generation timed out. You can retry."; agentOnboardingStreamManager.broadcast(session.id, { type: "error", data: session.error }); abortController.abort(); }, GENERATION_TIMEOUT_MS); activeGenerations.set(session.id, { abortController, timer }); try { return await operation(); } finally { clearTimeout(timer); activeGenerations.delete(session.id); } } async function continueConversation(session: Session, message: string): Promise { if (!session.agent) throw new Error("Session agent not initialized"); session.thinkingOutput = ""; try { await runGenerationWithTimeout(session, async () => { await session.agent.session.prompt(message); const assistant = (session.agent.session.state.messages as Array<{ role: string; content?: string | Array<{ type: string; text: string }> }>).filter((m) => m.role === "assistant").pop(); let responseText = session.thinkingOutput; if (assistant?.content) { if (typeof assistant.content === "string") responseText = assistant.content; else responseText = assistant.content.filter((c) => c.type === "text").map((c) => c.text).join(""); } const parsed = parseAgentOnboardingResponse(responseText); session.error = undefined; session.updatedAt = new Date(); if (parsed.type === "question") { session.currentQuestion = parsed.data; agentOnboardingStreamManager.broadcast(session.id, { type: "question", data: parsed.data }); } else { session.summary = parsed.data; session.currentQuestion = undefined; agentOnboardingStreamManager.broadcast(session.id, { type: "summary", data: parsed.data }); agentOnboardingStreamManager.broadcast(session.id, { type: "complete" }); } }); } catch (err) { session.error = err instanceof Error ? err.message : String(err); agentOnboardingStreamManager.broadcast(session.id, { type: "error", data: session.error }); } } export async function respondToAgentOnboarding(sessionId: string, responses: Record): Promise { const session = sessions.get(sessionId); if (!session) throw new SessionNotFoundError(`Agent onboarding session ${sessionId} not found or expired`); if (!session.currentQuestion) throw new InvalidSessionStateError("No active question in session"); session.history.push({ question: session.currentQuestion, response: responses }); const formatted = `Question: ${session.currentQuestion.question}\nAnswer: ${JSON.stringify(responses)}`; await continueConversation(session, formatted); } export async function retryAgentOnboardingSession(sessionId: string): Promise { const session = sessions.get(sessionId); if (!session) throw new SessionNotFoundError(`Agent onboarding session ${sessionId} not found or expired`); if (!session.error) throw new InvalidSessionStateError("Session is not in an error state"); session.error = undefined; const retryPrompt = session.currentQuestion ? `Please continue from the last question: ${session.currentQuestion.question}` : "Please continue and ask the next best onboarding question."; await continueConversation(session, retryPrompt); } export function stopAgentOnboardingGeneration(sessionId: string): boolean { const active = activeGenerations.get(sessionId); if (!active) return false; clearTimeout(active.timer); active.abortController.abort(); activeGenerations.delete(sessionId); const session = sessions.get(sessionId); if (session) { session.error = "Generation stopped by user. You can retry."; agentOnboardingStreamManager.broadcast(session.id, { type: "error", data: session.error }); } return true; } export async function cancelAgentOnboardingSession(sessionId: string): Promise { const session = sessions.get(sessionId); if (!session) throw new SessionNotFoundError(`Agent onboarding session ${sessionId} not found or expired`); stopAgentOnboardingGeneration(sessionId); try { session.agent?.session.dispose?.(); } catch {} sessions.delete(sessionId); agentOnboardingStreamManager.cleanupSession(sessionId); } export function getAgentOnboardingSession(sessionId: string): Session | undefined { return sessions.get(sessionId); } export function getAgentOnboardingSummary(sessionId: string): AgentOnboardingSummary | undefined { return sessions.get(sessionId)?.summary; } export function __resetAgentOnboardingState(): void { for (const sessionId of sessions.keys()) { void cancelAgentOnboardingSession(sessionId).catch(() => {}); } sessions.clear(); activeGenerations.clear(); } setInterval(() => { const now = Date.now(); for (const [id, session] of sessions) { if (now - session.updatedAt.getTime() > SESSION_TTL_MS) { void cancelAgentOnboardingSession(id).catch(() => {}); } } }, CLEANUP_INTERVAL_MS).unref?.(); export class SessionNotFoundError extends Error { constructor(message: string) { super(message); this.name = "SessionNotFoundError"; } } export class InvalidSessionStateError extends Error { constructor(message: string) { super(message); this.name = "InvalidSessionStateError"; } }