From 8f95270b1268325a06b24039ef168670e66f7e1e Mon Sep 17 00:00:00 2001 From: Mark Henderson Date: Mon, 9 Mar 2026 22:32:01 -0700 Subject: [PATCH] feat: Assist COO routes through Orchestrator on agent runner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The advisor route now proxies to /orchestrator/chat on agents.vibnai.com instead of calling Gemini directly. The Orchestrator (Claude Sonnet 4.6) has full tool access — Gitea, Coolify, web search, memory, agent spawning. - Build project knowledge_context from DB (name, vision, repo, PRD, phases, apps, recent sessions) and inject as COO persona + data - Convert frontend history format (model→assistant) for the orchestrator - Return orchestrator reply as streaming text response - Session scoped per project for in-memory context persistence Made-with: Cursor --- app/api/projects/[projectId]/advisor/route.ts | 308 ++++++++---------- 1 file changed, 130 insertions(+), 178 deletions(-) diff --git a/app/api/projects/[projectId]/advisor/route.ts b/app/api/projects/[projectId]/advisor/route.ts index 93bed82..0080870 100644 --- a/app/api/projects/[projectId]/advisor/route.ts +++ b/app/api/projects/[projectId]/advisor/route.ts @@ -1,127 +1,111 @@ +/** + * Assist COO — proxies to the agent runner's Orchestrator. + * + * The Orchestrator (Claude Sonnet 4.6, Tier B) has full tool access: + * Gitea — read repos, files, issues, commits + * Coolify — app status, deploy logs, trigger deploys + * Web search, memory, agent spawning + * + * This route loads project-specific context (PRD, phases, apps, sessions) + * and injects it as knowledge_context into the orchestrator's system prompt. + */ import { NextRequest } from 'next/server'; import { getServerSession } from 'next-auth'; import { authOptions } from '@/lib/auth/authOptions'; import { query } from '@/lib/db-postgres'; -const MODEL = process.env.GEMINI_MODEL ?? 'gemini-2.5-flash'; +const AGENT_RUNNER_URL = process.env.AGENT_RUNNER_URL ?? 'https://agents.vibnai.com'; // --------------------------------------------------------------------------- -// Context loaders — all best-effort, never block the response +// Context loader — everything the COO needs to know about the project // --------------------------------------------------------------------------- -interface SavedPhase { - phase: string; - title: string; - summary: string; - data: Record; - saved_at: string; -} - -interface AgentSession { - id: string; - task: string; - status: string; - created_at: string; - result?: string; -} - -async function loadPhases(projectId: string): Promise { - try { - const rows = await query( - `SELECT phase, title, summary, data, saved_at - FROM atlas_phases WHERE project_id = $1 - ORDER BY saved_at ASC`, +async function buildKnowledgeContext(projectId: string, email: string): Promise { + const [projectRows, phaseRows, sessionRows] = await Promise.all([ + query<{ data: Record }>( + `SELECT p.data FROM fs_projects p + JOIN fs_users u ON u.id = p.user_id + WHERE p.id = $1 AND u.data->>'email' = $2 LIMIT 1`, + [projectId, email] + ).catch(() => [] as { data: Record }[]), + query<{ phase: string; title: string; summary: string }>( + `SELECT phase, title, summary FROM atlas_phases + WHERE project_id = $1 ORDER BY saved_at ASC`, [projectId] - ); - return rows; - } catch { return []; } -} - -async function loadRecentSessions(projectId: string): Promise { - try { - const rows = await query( - `SELECT id, data->>'task' as task, data->>'status' as status, - data->>'createdAt' as created_at, data->>'result' as result - FROM fs_sessions - WHERE data->>'projectId' = $1 - ORDER BY created_at DESC LIMIT 10`, + ).catch(() => [] as { phase: string; title: string; summary: string }[]), + query<{ task: string; status: string }>( + `SELECT data->>'task' as task, data->>'status' as status + FROM fs_sessions WHERE data->>'projectId' = $1 + ORDER BY created_at DESC LIMIT 8`, [projectId] - ); - return rows.filter(r => r.task); - } catch { return []; } -} + ).catch(() => [] as { task: string; status: string }[]), + ]); -// --------------------------------------------------------------------------- -// System prompt — only states what it actually has -// --------------------------------------------------------------------------- + const d = projectRows[0]?.data ?? {}; + const name = (d.name as string) ?? 'Unknown Project'; + const vision = (d.productVision as string) ?? (d.vision as string) ?? ''; + const giteaRepo = (d.giteaRepo as string) ?? ''; + const prd = (d.prd as string) ?? ''; + const apps = (d.apps as Array<{ name: string; domain?: string; coolifyServiceUuid?: string }>) ?? []; + const coolifyProjectUuid = (d.coolifyProjectUuid as string) ?? ''; + const theiaUrl = (d.theiaWorkspaceUrl as string) ?? ''; -function buildSystemPrompt( - projectData: Record, - phases: SavedPhase[], - sessions: AgentSession[], -): string { - const name = (projectData.name as string) ?? 'this project'; - const vision = (projectData.productVision as string) ?? (projectData.vision as string) ?? ''; - const repo = (projectData.giteaRepo as string) ?? ''; - const prd = (projectData.prd as string) ?? ''; - const apps = (projectData.apps as Array<{ name: string; domain?: string }>) ?? []; - const theiaUrl = (projectData.theiaWorkspaceUrl as string) ?? ''; + const lines: string[] = []; - // PRD / discovery phases - let prdSection = ''; + // COO persona — injected so the orchestrator knows its role for this session + lines.push(`## Your role for this conversation +You are the personal AI COO for "${name}" — a trusted executive partner to the founder. +The founder talks to you. You figure out what needs to happen and get it done. +You delegate to specialist agents (Coder, PM, Marketing) when work is needed. + +Operating principles: +- Use your tools proactively. Don't guess — check Gitea for what's been built, check Coolify for app status. +- Before delegating any work: state the scope in plain English and confirm with the founder. +- Be brief. No preamble, no "Great question!". +- You decide the technical approach — never ask the founder to choose. +- Be honest when you're uncertain or when data isn't available. +- Do NOT spawn agents on the protected platform repos (vibn-frontend, theia-code-os, vibn-agent-runner, vibn-api, master-ai).`); + + // Project identity + lines.push(`\n## Project: ${name}`); + if (vision) lines.push(`Vision: ${vision}`); + if (giteaRepo) lines.push(`Gitea repo: ${giteaRepo} — use read_repo_file and list_repos to explore it`); + if (coolifyProjectUuid) lines.push(`Coolify project UUID: ${coolifyProjectUuid} — use coolify_list_applications to find its apps`); + if (theiaUrl) lines.push(`Theia IDE: ${theiaUrl}`); + + // PRD or discovery phases if (prd) { - prdSection = `\n## Product Requirements Document (PRD)\n${prd.slice(0, 3000)}${prd.length > 3000 ? '\n[…truncated]' : ''}`; - } else if (phases.length > 0) { - const phaseLines = phases.map(p => - `### ${p.title} (${p.phase})\n${p.summary}\n${ - Object.entries(p.data) - .filter(([, v]) => v !== null && v !== '' && !Array.isArray(v)) - .slice(0, 6) - .map(([k, v]) => `- ${k.replace(/_/g, ' ')}: ${v}`) - .join('\n') - }` - ).join('\n\n'); - prdSection = `\n## Discovery phases completed (${phases.length})\n${phaseLines}`; + lines.push(`\n## Product Requirements Document\n${prd.slice(0, 2500)}${prd.length > 2500 ? '\n[truncated — ask the founder for more detail]' : ''}`); + } else if (phaseRows.length > 0) { + lines.push(`\n## Discovery phases completed (${phaseRows.length})`); + for (const p of phaseRows) { + lines.push(`- ${p.title}: ${p.summary}`); + } + lines.push(`(PRD not yet finalized — Atlas discovery is in progress)`); + } else { + lines.push(`\n## Product discovery: not yet started`); } - // Apps / deployment - let deploySection = ''; + // Deployed apps if (apps.length > 0) { - const appLines = apps.map(a => `- ${a.name}${a.domain ? ` → https://${a.domain}` : ''}`).join('\n'); - deploySection = `\n## Deployed apps\n${appLines}`; + lines.push(`\n## Deployed apps`); + for (const a of apps) { + const url = a.domain ? `https://${a.domain}` : '(no domain yet)'; + const uuid = a.coolifyServiceUuid ? ` [uuid: ${a.coolifyServiceUuid}]` : ''; + lines.push(`- ${a.name} → ${url}${uuid}`); + } } // Recent agent work - let sessionsSection = ''; - if (sessions.length > 0) { - const lines = sessions - .slice(0, 6) - .map(s => `- [${s.status ?? 'unknown'}] ${s.task}`) - .join('\n'); - sessionsSection = `\n## Recent agent sessions (what's been worked on)\n${lines}`; + const validSessions = sessionRows.filter(s => s.task); + if (validSessions.length > 0) { + lines.push(`\n## Recent agent sessions (what's been worked on)`); + for (const s of validSessions) { + lines.push(`- [${s.status ?? 'unknown'}] ${s.task}`); + } } - return `You are the AI COO for "${name}" — an executive-level product partner, not a chatbot. - -Your role: the founder talks to you first. You reason across product, code, growth, and operations. You surface what matters, propose clear plans, and delegate to specialist agents when needed. - -## What you know about this project -${vision ? `Vision: ${vision}` : ''} -${repo ? `Codebase: ${repo}` : ''} -${theiaUrl ? `IDE workspace: ${theiaUrl}` : ''} -${prdSection} -${deploySection} -${sessionsSection} - -## Your operating principles -- You are an executive partner, not an assistant. You have opinions. -- You reason across the full business — product, tech, growth, ops — not just the question asked. -- You surface what the founder hasn't asked about when it's relevant. -- Before any work starts: state the scope in plain language, then ask "Want me to proceed?" -- Be brief. Founders are busy. No preamble, no "Great question!". -- Never ask the founder to pick a technical approach — you decide and explain your reasoning. -- When you don't have data (e.g. analytics, recent commits), say so directly rather than guessing. -- Be honest about the limits of your current context.`.trim(); + return lines.join('\n'); } // --------------------------------------------------------------------------- @@ -139,13 +123,6 @@ export async function POST( return new Response('Unauthorized', { status: 401 }); } - const GOOGLE_API_KEY = process.env.GOOGLE_API_KEY ?? ''; - if (!GOOGLE_API_KEY) { - return new Response('GOOGLE_API_KEY not configured', { status: 500 }); - } - - const STREAM_URL = `https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:streamGenerateContent?key=${GOOGLE_API_KEY}&alt=sse`; - const { message, history = [] } = await req.json() as { message: string; history: Array<{ role: 'user' | 'model'; content: string }>; @@ -155,83 +132,58 @@ export async function POST( return new Response('Message required', { status: 400 }); } - // Load all context in parallel — never block on failure - let projectData: Record = {}; - let phases: SavedPhase[] = []; - let sessions: AgentSession[] = []; - + // Load project context (best-effort) + let knowledgeContext = ''; try { - const [projectRows, phasesData, sessionsData] = await Promise.all([ - query<{ data: Record }>( - `SELECT p.data FROM fs_projects p - JOIN fs_users u ON u.id = p.user_id - WHERE p.id = $1 AND u.data->>'email' = $2 LIMIT 1`, - [projectId, session.user.email] - ), - loadPhases(projectId), - loadRecentSessions(projectId), - ]); - if (projectRows.length > 0) projectData = projectRows[0].data ?? {}; - phases = phasesData; - sessions = sessionsData; - } catch { /* proceed with whatever loaded */ } + knowledgeContext = await buildKnowledgeContext(projectId, session.user.email); + } catch { /* proceed without — orchestrator still works */ } - const systemPrompt = buildSystemPrompt(projectData, phases, sessions); + // Convert history: frontend uses "model", orchestrator uses "assistant" + const llmHistory = history + .filter(h => h.content?.trim()) + .map(h => ({ + role: (h.role === 'model' ? 'assistant' : 'user') as 'assistant' | 'user', + content: h.content, + })); - const contents = [ - ...history.map(h => ({ - role: h.role, - parts: [{ text: h.content }], - })), - { role: 'user' as const, parts: [{ text: message }] }, - ]; - - const geminiRes = await fetch(STREAM_URL, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - system_instruction: { parts: [{ text: systemPrompt }] }, - contents, - generationConfig: { - temperature: 0.6, - maxOutputTokens: 1500, - }, - }), - }); - - if (!geminiRes.ok) { - const err = await geminiRes.text(); - return new Response(`Gemini error: ${err}`, { status: 500 }); + // Call the orchestrator on the agent runner + let orchRes: Response; + try { + orchRes = await fetch(`${AGENT_RUNNER_URL}/orchestrator/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + message, + // Scoped session per project so in-memory context persists within a browser session + session_id: `coo_${projectId}_${session.user.email.split('@')[0]}`, + history: llmHistory, + knowledge_context: knowledgeContext, + }), + }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return new Response(`Agent runner unreachable: ${msg}`, { status: 502 }); } - // Proxy SSE stream — extract text deltas only + if (!orchRes.ok) { + const err = await orchRes.text(); + return new Response(`Orchestrator error: ${err}`, { status: 502 }); + } + + const result = await orchRes.json() as { reply?: string; error?: string }; + + if (result.error) { + return new Response(result.error, { status: 500 }); + } + + const reply = result.reply ?? '(no response)'; + + // Return as a streaming response — single chunk (orchestrator is non-streaming) const encoder = new TextEncoder(); const stream = new ReadableStream({ - async start(controller) { - const reader = geminiRes.body!.getReader(); - const decoder = new TextDecoder(); - let buf = ''; - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - buf += decoder.decode(value, { stream: true }); - const lines = buf.split('\n'); - buf = lines.pop() ?? ''; - for (const line of lines) { - if (!line.startsWith('data: ')) continue; - const json = line.slice(6).trim(); - if (!json || json === '[DONE]') continue; - try { - const chunk = JSON.parse(json); - const text = chunk.candidates?.[0]?.content?.parts?.[0]?.text; - if (text) controller.enqueue(encoder.encode(text)); - } catch { /* skip malformed */ } - } - } - } finally { - controller.close(); - } + start(controller) { + controller.enqueue(encoder.encode(reply)); + controller.close(); }, });