feat: Master Orchestrator — persistent chat with full project context and awareness tools

Made-with: Cursor
This commit is contained in:
2026-02-26 15:53:58 -08:00
parent 7d426c36e2
commit 5cb1e82169
7 changed files with 821 additions and 0 deletions

19
dist/orchestrator.d.ts vendored Normal file
View 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
View 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
View File

@@ -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
View File

@@ -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
View 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 };
}

View File

@@ -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);

View File

@@ -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();
}