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 job_store_1 = require("./job-store");
|
||||||
const agent_runner_1 = require("./agent-runner");
|
const agent_runner_1 = require("./agent-runner");
|
||||||
const agents_1 = require("./agents");
|
const agents_1 = require("./agents");
|
||||||
|
const orchestrator_1 = require("./orchestrator");
|
||||||
const app = (0, express_1.default)();
|
const app = (0, express_1.default)();
|
||||||
app.use((0, cors_1.default)());
|
app.use((0, cors_1.default)());
|
||||||
// Raw body capture for webhook HMAC — must come before express.json()
|
// 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);
|
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
|
// List recent jobs
|
||||||
app.get('/api/jobs', (req, res) => {
|
app.get('/api/jobs', (req, res) => {
|
||||||
const limit = parseInt(req.query.limit || '20', 10);
|
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']
|
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_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 '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);
|
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:
|
default:
|
||||||
return { error: `Unknown tool: ${name}` };
|
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)}` };
|
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();
|
||||||
|
}
|
||||||
|
|||||||
209
src/orchestrator.ts
Normal file
209
src/orchestrator.ts
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
import { GoogleGenAI, Content } from '@google/genai';
|
||||||
|
import { ALL_TOOLS, executeTool, ToolContext } from './tools';
|
||||||
|
|
||||||
|
const MAX_TURNS = 20;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Session store — conversation history per session_id
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface Session {
|
||||||
|
id: string;
|
||||||
|
history: Content[];
|
||||||
|
createdAt: string;
|
||||||
|
lastActiveAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessions = new Map<string, Session>();
|
||||||
|
|
||||||
|
function getOrCreateSession(sessionId: string): Session {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listSessions() {
|
||||||
|
return Array.from(sessions.values()).map(s => ({
|
||||||
|
id: s.id,
|
||||||
|
messages: s.history.length,
|
||||||
|
createdAt: s.createdAt,
|
||||||
|
lastActiveAt: s.lastActiveAt
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearSession(sessionId: string) {
|
||||||
|
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`;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main chat function
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface ChatMessage {
|
||||||
|
role: 'user' | 'assistant';
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatResult {
|
||||||
|
reply: string;
|
||||||
|
sessionId: string;
|
||||||
|
turns: number;
|
||||||
|
toolCalls: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function orchestratorChat(
|
||||||
|
sessionId: string,
|
||||||
|
userMessage: string,
|
||||||
|
ctx: ToolContext
|
||||||
|
): Promise<ChatResult> {
|
||||||
|
const apiKey = process.env.GOOGLE_API_KEY;
|
||||||
|
if (!apiKey) throw new Error('GOOGLE_API_KEY not set');
|
||||||
|
|
||||||
|
const genai = new GoogleGenAI({ apiKey });
|
||||||
|
const session = getOrCreateSession(sessionId);
|
||||||
|
|
||||||
|
// Orchestrator gets ALL tools
|
||||||
|
const functionDeclarations = ALL_TOOLS.map(t => ({
|
||||||
|
name: t.name,
|
||||||
|
description: t.description,
|
||||||
|
parameters: t.parameters as any
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Add user message to history
|
||||||
|
session.history.push({ role: 'user', parts: [{ text: userMessage }] });
|
||||||
|
|
||||||
|
let turn = 0;
|
||||||
|
let finalReply = '';
|
||||||
|
const toolCallNames: string[] = [];
|
||||||
|
|
||||||
|
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: Content = {
|
||||||
|
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: any[] = [];
|
||||||
|
for (const part of functionCalls) {
|
||||||
|
const call = part.functionCall!;
|
||||||
|
const callName = call.name ?? 'unknown';
|
||||||
|
const callArgs = (call.args ?? {}) as Record<string, unknown>;
|
||||||
|
toolCallNames.push(callName);
|
||||||
|
|
||||||
|
let result: unknown;
|
||||||
|
try {
|
||||||
|
result = await 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 };
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import { createJob, getJob, listJobs, updateJob } from './job-store';
|
|||||||
import { runAgent } from './agent-runner';
|
import { runAgent } from './agent-runner';
|
||||||
import { AGENTS } from './agents';
|
import { AGENTS } from './agents';
|
||||||
import { ToolContext } from './tools';
|
import { ToolContext } from './tools';
|
||||||
|
import { orchestratorChat, listSessions, clearSession } from './orchestrator';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
@@ -164,6 +165,34 @@ app.get('/api/jobs/:id', (req: Request, res: Response) => {
|
|||||||
res.json(job);
|
res.json(job);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Orchestrator — persistent chat with full project context
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
app.post('/orchestrator/chat', async (req: Request, res: Response) => {
|
||||||
|
const { message, session_id } = req.body as { message?: string; session_id?: string };
|
||||||
|
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 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: Request, res: Response) => {
|
||||||
|
res.json(listSessions());
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/orchestrator/sessions/:id', (req: Request, res: Response) => {
|
||||||
|
clearSession(req.params.id);
|
||||||
|
res.json({ cleared: req.params.id });
|
||||||
|
});
|
||||||
|
|
||||||
// List recent jobs
|
// List recent jobs
|
||||||
app.get('/api/jobs', (req: Request, res: Response) => {
|
app.get('/api/jobs', (req: Request, res: Response) => {
|
||||||
const limit = parseInt((req.query.limit as string) || '20', 10);
|
const limit = parseInt((req.query.limit as string) || '20', 10);
|
||||||
|
|||||||
190
src/tools.ts
190
src/tools.ts
@@ -219,6 +219,76 @@ export const ALL_TOOLS: ToolDefinition[] = [
|
|||||||
},
|
},
|
||||||
required: ['agent', 'task', 'repo']
|
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']
|
||||||
|
}
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -248,6 +318,14 @@ export async function executeTool(
|
|||||||
case 'gitea_list_issues': return giteaListIssues(String(args.repo), (args.state as string) || 'open', ctx);
|
case 'gitea_list_issues': return giteaListIssues(String(args.repo), (args.state as string) || 'open', ctx);
|
||||||
case 'gitea_close_issue': return giteaCloseIssue(String(args.repo), Number(args.issue_number), 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);
|
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 as string | undefined, (args.state as string) || '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:
|
default:
|
||||||
return { error: `Unknown tool: ${name}` };
|
return { error: `Unknown tool: ${name}` };
|
||||||
}
|
}
|
||||||
@@ -481,3 +559,115 @@ async function spawnAgentTool(agent: string, task: string, repo: string, _ctx: T
|
|||||||
return { error: `Failed to spawn agent: ${err instanceof Error ? err.message : String(err)}` };
|
return { error: `Failed to spawn agent: ${err instanceof Error ? err.message : String(err)}` };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Orchestrator tools — project-wide awareness
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function listRepos(ctx: ToolContext): Promise<unknown> {
|
||||||
|
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() as any;
|
||||||
|
return (data.data || []).map((r: any) => ({
|
||||||
|
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: string | undefined, state: string, ctx: ToolContext): Promise<unknown> {
|
||||||
|
if (repo) {
|
||||||
|
return giteaFetch(`/repos/${repo}/issues?state=${state}&limit=20`, ctx);
|
||||||
|
}
|
||||||
|
// Fetch across all repos
|
||||||
|
const repos = await listRepos(ctx) as any[];
|
||||||
|
const allIssues: unknown[] = [];
|
||||||
|
for (const r of repos.slice(0, 10)) {
|
||||||
|
const issues = await giteaFetch(`/repos/${r.name}/issues?state=${state}&limit=10`, ctx) as any[];
|
||||||
|
if (Array.isArray(issues)) {
|
||||||
|
allIssues.push(...issues.map((i: any) => ({
|
||||||
|
repo: r.name,
|
||||||
|
number: i.number,
|
||||||
|
title: i.title,
|
||||||
|
state: i.state,
|
||||||
|
labels: i.labels?.map((l: any) => l.name),
|
||||||
|
created: i.created_at
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return allIssues;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listAllApps(ctx: ToolContext): Promise<unknown> {
|
||||||
|
const apps = await coolifyFetch('/applications', ctx) as any[];
|
||||||
|
if (!Array.isArray(apps)) return apps;
|
||||||
|
return apps.map((a: any) => ({
|
||||||
|
uuid: a.uuid,
|
||||||
|
name: a.name,
|
||||||
|
fqdn: a.fqdn,
|
||||||
|
status: a.status,
|
||||||
|
repo: a.git_repository,
|
||||||
|
branch: a.git_branch
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAppStatus(appName: string, ctx: ToolContext): Promise<unknown> {
|
||||||
|
const apps = await coolifyFetch('/applications', ctx) as any[];
|
||||||
|
if (!Array.isArray(apps)) return apps;
|
||||||
|
const app = apps.find((a: any) =>
|
||||||
|
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: string, filePath: string, ctx: ToolContext): Promise<unknown> {
|
||||||
|
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() as any;
|
||||||
|
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: string): Promise<unknown> {
|
||||||
|
const runnerUrl = process.env.AGENT_RUNNER_URL || 'http://localhost:3333';
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${runnerUrl}/api/jobs/${jobId}`);
|
||||||
|
const job = await res.json() as any;
|
||||||
|
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: string, ctx: ToolContext): Promise<unknown> {
|
||||||
|
const apps = await coolifyFetch('/applications', ctx) as any[];
|
||||||
|
if (!Array.isArray(apps)) return apps;
|
||||||
|
const app = apps.find((a: any) =>
|
||||||
|
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