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:
@@ -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<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();
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user