feat: Master Orchestrator — persistent chat with full project context and awareness tools
Made-with: Cursor
This commit is contained in:
19
dist/orchestrator.d.ts
vendored
Normal file
19
dist/orchestrator.d.ts
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
import { ToolContext } from './tools';
|
||||
export declare function listSessions(): {
|
||||
id: string;
|
||||
messages: number;
|
||||
createdAt: string;
|
||||
lastActiveAt: string;
|
||||
}[];
|
||||
export declare function clearSession(sessionId: string): void;
|
||||
export interface ChatMessage {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
}
|
||||
export interface ChatResult {
|
||||
reply: string;
|
||||
sessionId: string;
|
||||
turns: number;
|
||||
toolCalls: string[];
|
||||
}
|
||||
export declare function orchestratorChat(sessionId: string, userMessage: string, ctx: ToolContext): Promise<ChatResult>;
|
||||
162
dist/orchestrator.js
vendored
Normal file
162
dist/orchestrator.js
vendored
Normal file
@@ -0,0 +1,162 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.listSessions = listSessions;
|
||||
exports.clearSession = clearSession;
|
||||
exports.orchestratorChat = orchestratorChat;
|
||||
const genai_1 = require("@google/genai");
|
||||
const tools_1 = require("./tools");
|
||||
const MAX_TURNS = 20;
|
||||
const sessions = new Map();
|
||||
function getOrCreateSession(sessionId) {
|
||||
if (!sessions.has(sessionId)) {
|
||||
sessions.set(sessionId, {
|
||||
id: sessionId,
|
||||
history: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
lastActiveAt: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
const session = sessions.get(sessionId);
|
||||
session.lastActiveAt = new Date().toISOString();
|
||||
return session;
|
||||
}
|
||||
function listSessions() {
|
||||
return Array.from(sessions.values()).map(s => ({
|
||||
id: s.id,
|
||||
messages: s.history.length,
|
||||
createdAt: s.createdAt,
|
||||
lastActiveAt: s.lastActiveAt
|
||||
}));
|
||||
}
|
||||
function clearSession(sessionId) {
|
||||
sessions.delete(sessionId);
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// Orchestrator system prompt — full Vibn context
|
||||
// ---------------------------------------------------------------------------
|
||||
const SYSTEM_PROMPT = `You are the Master Orchestrator for Vibn — an AI-powered cloud development platform.
|
||||
|
||||
You are always running. You have full awareness of the Vibn project and can take autonomous action.
|
||||
|
||||
## What Vibn is
|
||||
Vibn is a platform that lets developers build products using AI agents. It includes:
|
||||
- A cloud IDE (Theia) at theia.vibnai.com
|
||||
- A frontend app (Next.js) at vibnai.com
|
||||
- A backend API at api.vibnai.com
|
||||
- An agent runner (this system) at agents.vibnai.com
|
||||
- Self-hosted Git at git.vibnai.com
|
||||
- Self-hosted deployments via Coolify at coolify.vibnai.com
|
||||
|
||||
## Your capabilities
|
||||
You have access to tools that give you full project control:
|
||||
|
||||
**Awareness tools** (use these to understand current state):
|
||||
- list_repos — see all Git repositories
|
||||
- list_all_issues — see what work is open or in progress
|
||||
- list_all_apps — see all deployed apps and their status
|
||||
- get_app_status — check if a specific app is running and healthy
|
||||
- read_repo_file — read any file from any repo without cloning
|
||||
|
||||
**Action tools** (use these to get things done):
|
||||
- spawn_agent — dispatch Coder, PM, or Marketing agent to do work on a repo
|
||||
- get_job_status — check if a spawned agent job is done
|
||||
- deploy_app — trigger a Coolify deployment after code is committed
|
||||
- gitea_create_issue — create a tracked issue (also triggers agent webhook if labelled)
|
||||
- gitea_list_issues, gitea_close_issue — manage issue lifecycle
|
||||
|
||||
## Available agents you can spawn
|
||||
- **Coder** — writes code, edits files, runs commands, commits and pushes
|
||||
- **PM** — writes documentation, manages issues, creates reports
|
||||
- **Marketing** — writes copy, blog posts, release notes
|
||||
|
||||
## How you work
|
||||
1. When the user gives you a task, think about what needs to happen.
|
||||
2. Use awareness tools first to understand current state if needed.
|
||||
3. Break the task into concrete actions.
|
||||
4. Spawn the right agents with detailed, specific task descriptions.
|
||||
5. Check back on job status if the user wants to track progress.
|
||||
6. Report clearly what was done and what's next.
|
||||
|
||||
## Your personality
|
||||
- Direct and clear. No fluff.
|
||||
- Proactive — if you notice something that needs fixing, mention it.
|
||||
- Honest about what you can and can't do.
|
||||
- You speak for the whole system, not just one agent.
|
||||
|
||||
## Important context
|
||||
- All repos are owned by "mark" on git.vibnai.com
|
||||
- The main repos are: vibn-frontend, vibn-api, vibn-agent-runner, theia-code-os
|
||||
- The stack: Next.js (frontend), Node.js (API + agent runner), Theia (IDE)
|
||||
- Coolify manages all deployments on server 34.19.250.135 (Montreal)
|
||||
- Agent label routing: agent:coder, agent:pm, agent:marketing on Gitea issues`;
|
||||
async function orchestratorChat(sessionId, userMessage, ctx) {
|
||||
const apiKey = process.env.GOOGLE_API_KEY;
|
||||
if (!apiKey)
|
||||
throw new Error('GOOGLE_API_KEY not set');
|
||||
const genai = new genai_1.GoogleGenAI({ apiKey });
|
||||
const session = getOrCreateSession(sessionId);
|
||||
// Orchestrator gets ALL tools
|
||||
const functionDeclarations = tools_1.ALL_TOOLS.map(t => ({
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
parameters: t.parameters
|
||||
}));
|
||||
// Add user message to history
|
||||
session.history.push({ role: 'user', parts: [{ text: userMessage }] });
|
||||
let turn = 0;
|
||||
let finalReply = '';
|
||||
const toolCallNames = [];
|
||||
while (turn < MAX_TURNS) {
|
||||
turn++;
|
||||
const response = await genai.models.generateContent({
|
||||
model: 'gemini-2.5-flash',
|
||||
contents: session.history,
|
||||
config: {
|
||||
systemInstruction: SYSTEM_PROMPT,
|
||||
tools: [{ functionDeclarations }],
|
||||
temperature: 0.3,
|
||||
maxOutputTokens: 8192
|
||||
}
|
||||
});
|
||||
const candidate = response.candidates?.[0];
|
||||
if (!candidate)
|
||||
throw new Error('No response from Gemini');
|
||||
const modelContent = {
|
||||
role: 'model',
|
||||
parts: candidate.content?.parts || []
|
||||
};
|
||||
session.history.push(modelContent);
|
||||
const functionCalls = candidate.content?.parts?.filter(p => p.functionCall) ?? [];
|
||||
// No more tool calls — we have the final answer
|
||||
if (functionCalls.length === 0) {
|
||||
finalReply = candidate.content?.parts
|
||||
?.filter(p => p.text)
|
||||
.map(p => p.text)
|
||||
.join('') ?? '';
|
||||
break;
|
||||
}
|
||||
// Execute tool calls
|
||||
const toolResultParts = [];
|
||||
for (const part of functionCalls) {
|
||||
const call = part.functionCall;
|
||||
const callName = call.name ?? 'unknown';
|
||||
const callArgs = (call.args ?? {});
|
||||
toolCallNames.push(callName);
|
||||
let result;
|
||||
try {
|
||||
result = await (0, tools_1.executeTool)(callName, callArgs, ctx);
|
||||
}
|
||||
catch (err) {
|
||||
result = { error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
toolResultParts.push({
|
||||
functionResponse: { name: callName, response: { result } }
|
||||
});
|
||||
}
|
||||
session.history.push({ role: 'user', parts: toolResultParts });
|
||||
}
|
||||
if (turn >= MAX_TURNS && !finalReply) {
|
||||
finalReply = 'I hit the turn limit. Please try a more specific request.';
|
||||
}
|
||||
return { reply: finalReply, sessionId, turns: turn, toolCalls: toolCallNames };
|
||||
}
|
||||
27
dist/server.js
vendored
27
dist/server.js
vendored
@@ -45,6 +45,7 @@ const child_process_1 = require("child_process");
|
||||
const job_store_1 = require("./job-store");
|
||||
const agent_runner_1 = require("./agent-runner");
|
||||
const agents_1 = require("./agents");
|
||||
const orchestrator_1 = require("./orchestrator");
|
||||
const app = (0, express_1.default)();
|
||||
app.use((0, cors_1.default)());
|
||||
// Raw body capture for webhook HMAC — must come before express.json()
|
||||
@@ -155,6 +156,32 @@ app.get('/api/jobs/:id', (req, res) => {
|
||||
}
|
||||
res.json(job);
|
||||
});
|
||||
// ---------------------------------------------------------------------------
|
||||
// Orchestrator — persistent chat with full project context
|
||||
// ---------------------------------------------------------------------------
|
||||
app.post('/orchestrator/chat', async (req, res) => {
|
||||
const { message, session_id } = req.body;
|
||||
if (!message) {
|
||||
res.status(400).json({ error: '"message" is required' });
|
||||
return;
|
||||
}
|
||||
const sessionId = session_id || `session_${Date.now()}`;
|
||||
const ctx = buildContext();
|
||||
try {
|
||||
const result = await (0, orchestrator_1.orchestratorChat)(sessionId, message, ctx);
|
||||
res.json(result);
|
||||
}
|
||||
catch (err) {
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
});
|
||||
app.get('/orchestrator/sessions', (_req, res) => {
|
||||
res.json((0, orchestrator_1.listSessions)());
|
||||
});
|
||||
app.delete('/orchestrator/sessions/:id', (req, res) => {
|
||||
(0, orchestrator_1.clearSession)(req.params.id);
|
||||
res.json({ cleared: req.params.id });
|
||||
});
|
||||
// List recent jobs
|
||||
app.get('/api/jobs', (req, res) => {
|
||||
const limit = parseInt(req.query.limit || '20', 10);
|
||||
|
||||
185
dist/tools.js
vendored
185
dist/tools.js
vendored
@@ -227,6 +227,75 @@ exports.ALL_TOOLS = [
|
||||
},
|
||||
required: ['agent', 'task', 'repo']
|
||||
}
|
||||
},
|
||||
// -------------------------------------------------------------------------
|
||||
// Orchestrator-only tools — project-wide awareness
|
||||
// -------------------------------------------------------------------------
|
||||
{
|
||||
name: 'list_repos',
|
||||
description: 'List all Git repositories in the Gitea organization. Returns repo names, descriptions, and last update time.',
|
||||
parameters: { type: 'object', properties: {} }
|
||||
},
|
||||
{
|
||||
name: 'list_all_issues',
|
||||
description: 'List open issues across all repos or a specific repo. Use this to understand what work is queued or in progress.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
repo: { type: 'string', description: 'Optional: "owner/name" to scope to one repo. Omit for all repos.' },
|
||||
state: { type: 'string', description: '"open", "closed", or "all". Default: "open"' }
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'list_all_apps',
|
||||
description: 'List all Coolify applications across all projects with their status (running/stopped/error) and domain.',
|
||||
parameters: { type: 'object', properties: {} }
|
||||
},
|
||||
{
|
||||
name: 'get_app_status',
|
||||
description: 'Get the current deployment status and recent logs for a specific Coolify application by name or UUID.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
app_name: { type: 'string', description: 'Application name (e.g. "vibn-frontend") or UUID' }
|
||||
},
|
||||
required: ['app_name']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'read_repo_file',
|
||||
description: 'Read a file from any Gitea repository without cloning it. Useful for understanding project structure.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
repo: { type: 'string', description: 'Repo in "owner/name" format' },
|
||||
path: { type: 'string', description: 'File path within the repo (e.g. "src/app/page.tsx")' }
|
||||
},
|
||||
required: ['repo', 'path']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'get_job_status',
|
||||
description: 'Check the status of a previously spawned agent job by job ID.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
job_id: { type: 'string', description: 'Job ID returned by spawn_agent' }
|
||||
},
|
||||
required: ['job_id']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'deploy_app',
|
||||
description: 'Trigger a Coolify deployment for an app by name. Use after an agent commits code.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
app_name: { type: 'string', description: 'Application name (e.g. "vibn-frontend")' }
|
||||
},
|
||||
required: ['app_name']
|
||||
}
|
||||
}
|
||||
];
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -250,6 +319,14 @@ async function executeTool(name, args, ctx) {
|
||||
case 'gitea_list_issues': return giteaListIssues(String(args.repo), args.state || 'open', ctx);
|
||||
case 'gitea_close_issue': return giteaCloseIssue(String(args.repo), Number(args.issue_number), ctx);
|
||||
case 'spawn_agent': return spawnAgentTool(String(args.agent), String(args.task), String(args.repo), ctx);
|
||||
// Orchestrator tools
|
||||
case 'list_repos': return listRepos(ctx);
|
||||
case 'list_all_issues': return listAllIssues(args.repo, args.state || 'open', ctx);
|
||||
case 'list_all_apps': return listAllApps(ctx);
|
||||
case 'get_app_status': return getAppStatus(String(args.app_name), ctx);
|
||||
case 'read_repo_file': return readRepoFile(String(args.repo), String(args.path), ctx);
|
||||
case 'get_job_status': return getJobStatus(String(args.job_id));
|
||||
case 'deploy_app': return deployApp(String(args.app_name), ctx);
|
||||
default:
|
||||
return { error: `Unknown tool: ${name}` };
|
||||
}
|
||||
@@ -475,3 +552,111 @@ async function spawnAgentTool(agent, task, repo, _ctx) {
|
||||
return { error: `Failed to spawn agent: ${err instanceof Error ? err.message : String(err)}` };
|
||||
}
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// Orchestrator tools — project-wide awareness
|
||||
// ---------------------------------------------------------------------------
|
||||
async function listRepos(ctx) {
|
||||
const res = await fetch(`${ctx.gitea.apiUrl}/api/v1/repos/search?limit=50&token=${ctx.gitea.apiToken}`, {
|
||||
headers: { 'Authorization': `token ${ctx.gitea.apiToken}` }
|
||||
});
|
||||
const data = await res.json();
|
||||
return (data.data || []).map((r) => ({
|
||||
name: r.full_name,
|
||||
description: r.description,
|
||||
default_branch: r.default_branch,
|
||||
updated: r.updated,
|
||||
stars: r.stars_count,
|
||||
open_issues: r.open_issues_count
|
||||
}));
|
||||
}
|
||||
async function listAllIssues(repo, state, ctx) {
|
||||
if (repo) {
|
||||
return giteaFetch(`/repos/${repo}/issues?state=${state}&limit=20`, ctx);
|
||||
}
|
||||
// Fetch across all repos
|
||||
const repos = await listRepos(ctx);
|
||||
const allIssues = [];
|
||||
for (const r of repos.slice(0, 10)) {
|
||||
const issues = await giteaFetch(`/repos/${r.name}/issues?state=${state}&limit=10`, ctx);
|
||||
if (Array.isArray(issues)) {
|
||||
allIssues.push(...issues.map((i) => ({
|
||||
repo: r.name,
|
||||
number: i.number,
|
||||
title: i.title,
|
||||
state: i.state,
|
||||
labels: i.labels?.map((l) => l.name),
|
||||
created: i.created_at
|
||||
})));
|
||||
}
|
||||
}
|
||||
return allIssues;
|
||||
}
|
||||
async function listAllApps(ctx) {
|
||||
const apps = await coolifyFetch('/applications', ctx);
|
||||
if (!Array.isArray(apps))
|
||||
return apps;
|
||||
return apps.map((a) => ({
|
||||
uuid: a.uuid,
|
||||
name: a.name,
|
||||
fqdn: a.fqdn,
|
||||
status: a.status,
|
||||
repo: a.git_repository,
|
||||
branch: a.git_branch
|
||||
}));
|
||||
}
|
||||
async function getAppStatus(appName, ctx) {
|
||||
const apps = await coolifyFetch('/applications', ctx);
|
||||
if (!Array.isArray(apps))
|
||||
return apps;
|
||||
const app = apps.find((a) => a.name?.toLowerCase() === appName.toLowerCase() || a.uuid === appName);
|
||||
if (!app)
|
||||
return { error: `App "${appName}" not found` };
|
||||
const logs = await coolifyFetch(`/applications/${app.uuid}/logs?limit=20`, ctx);
|
||||
return { name: app.name, uuid: app.uuid, status: app.status, fqdn: app.fqdn, logs };
|
||||
}
|
||||
async function readRepoFile(repo, filePath, ctx) {
|
||||
try {
|
||||
const res = await fetch(`${ctx.gitea.apiUrl}/api/v1/repos/${repo}/contents/${filePath}`, {
|
||||
headers: { 'Authorization': `token ${ctx.gitea.apiToken}` }
|
||||
});
|
||||
if (!res.ok)
|
||||
return { error: `File not found: ${filePath} in ${repo}` };
|
||||
const data = await res.json();
|
||||
const content = Buffer.from(data.content, 'base64').toString('utf8');
|
||||
return { repo, path: filePath, content };
|
||||
}
|
||||
catch (err) {
|
||||
return { error: `Failed to read ${filePath}: ${err instanceof Error ? err.message : String(err)}` };
|
||||
}
|
||||
}
|
||||
async function getJobStatus(jobId) {
|
||||
const runnerUrl = process.env.AGENT_RUNNER_URL || 'http://localhost:3333';
|
||||
try {
|
||||
const res = await fetch(`${runnerUrl}/api/jobs/${jobId}`);
|
||||
const job = await res.json();
|
||||
return {
|
||||
id: job.id,
|
||||
agent: job.agent,
|
||||
status: job.status,
|
||||
progress: job.progress,
|
||||
toolCalls: job.toolCalls?.length,
|
||||
result: job.result,
|
||||
error: job.error
|
||||
};
|
||||
}
|
||||
catch (err) {
|
||||
return { error: `Failed to get job: ${err instanceof Error ? err.message : String(err)}` };
|
||||
}
|
||||
}
|
||||
async function deployApp(appName, ctx) {
|
||||
const apps = await coolifyFetch('/applications', ctx);
|
||||
if (!Array.isArray(apps))
|
||||
return apps;
|
||||
const app = apps.find((a) => a.name?.toLowerCase() === appName.toLowerCase() || a.uuid === appName);
|
||||
if (!app)
|
||||
return { error: `App "${appName}" not found` };
|
||||
const result = await fetch(`${ctx.coolify.apiUrl}/api/v1/deploy?uuid=${app.uuid}&force=false`, {
|
||||
headers: { 'Authorization': `Bearer ${ctx.coolify.apiToken}` }
|
||||
});
|
||||
return result.json();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user