From 5cb1e821697255b3a8def0f9963b07a865b7d4fb Mon Sep 17 00:00:00 2001 From: mawkone Date: Thu, 26 Feb 2026 15:53:58 -0800 Subject: [PATCH] =?UTF-8?q?feat:=20Master=20Orchestrator=20=E2=80=94=20per?= =?UTF-8?q?sistent=20chat=20with=20full=20project=20context=20and=20awaren?= =?UTF-8?q?ess=20tools?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Made-with: Cursor --- dist/orchestrator.d.ts | 19 ++++ dist/orchestrator.js | 162 ++++++++++++++++++++++++++++++++ dist/server.js | 27 ++++++ dist/tools.js | 185 ++++++++++++++++++++++++++++++++++++ src/orchestrator.ts | 209 +++++++++++++++++++++++++++++++++++++++++ src/server.ts | 29 ++++++ src/tools.ts | 190 +++++++++++++++++++++++++++++++++++++ 7 files changed, 821 insertions(+) create mode 100644 dist/orchestrator.d.ts create mode 100644 dist/orchestrator.js create mode 100644 src/orchestrator.ts diff --git a/dist/orchestrator.d.ts b/dist/orchestrator.d.ts new file mode 100644 index 0000000..46c808b --- /dev/null +++ b/dist/orchestrator.d.ts @@ -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; diff --git a/dist/orchestrator.js b/dist/orchestrator.js new file mode 100644 index 0000000..cedbf2c --- /dev/null +++ b/dist/orchestrator.js @@ -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 }; +} diff --git a/dist/server.js b/dist/server.js index c91c7fc..cb8d70f 100644 --- a/dist/server.js +++ b/dist/server.js @@ -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); diff --git a/dist/tools.js b/dist/tools.js index 0e0dcd0..f508746 100644 --- a/dist/tools.js +++ b/dist/tools.js @@ -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(); +} diff --git a/src/orchestrator.ts b/src/orchestrator.ts new file mode 100644 index 0000000..1490064 --- /dev/null +++ b/src/orchestrator.ts @@ -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(); + +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 { + 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; + 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 }; +} diff --git a/src/server.ts b/src/server.ts index f1d0df0..52c744c 100644 --- a/src/server.ts +++ b/src/server.ts @@ -8,6 +8,7 @@ import { createJob, getJob, listJobs, updateJob } from './job-store'; import { runAgent } from './agent-runner'; import { AGENTS } from './agents'; import { ToolContext } from './tools'; +import { orchestratorChat, listSessions, clearSession } from './orchestrator'; const app = express(); app.use(cors()); @@ -164,6 +165,34 @@ app.get('/api/jobs/:id', (req: Request, res: Response) => { 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 app.get('/api/jobs', (req: Request, res: Response) => { const limit = parseInt((req.query.limit as string) || '20', 10); diff --git a/src/tools.ts b/src/tools.ts index 874e31d..5ea44c7 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -219,6 +219,76 @@ export const ALL_TOOLS: ToolDefinition[] = [ }, 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_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 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: 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)}` }; } } + +// --------------------------------------------------------------------------- +// Orchestrator tools — project-wide awareness +// --------------------------------------------------------------------------- + +async function listRepos(ctx: ToolContext): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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(); +}