feat: Assist COO routes through Orchestrator on agent runner

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
This commit is contained in:
2026-03-09 22:32:01 -07:00
parent ff0e1592fa
commit 8f95270b12

View File

@@ -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 loadersall 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<string, unknown>;
saved_at: string;
}
interface AgentSession {
id: string;
task: string;
status: string;
created_at: string;
result?: string;
}
async function loadPhases(projectId: string): Promise<SavedPhase[]> {
try {
const rows = await query<SavedPhase>(
`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<string> {
const [projectRows, phaseRows, sessionRows] = await Promise.all([
query<{ data: Record<string, unknown> }>(
`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<string, unknown> }[]),
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<AgentSession[]> {
try {
const rows = await query<AgentSession>(
`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<string, unknown>,
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<string, unknown> = {};
let phases: SavedPhase[] = [];
let sessions: AgentSession[] = [];
// Load project context (best-effort)
let knowledgeContext = '';
try {
const [projectRows, phasesData, sessionsData] = await Promise.all([
query<{ data: Record<string, unknown> }>(
`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();
},
});