diff --git a/src/orchestrator.ts b/src/orchestrator.ts index 1490064..529e473 100644 --- a/src/orchestrator.ts +++ b/src/orchestrator.ts @@ -1,15 +1,15 @@ -import { GoogleGenAI, Content } from '@google/genai'; +import { createLLM, toOAITools, LLMMessage } from './llm'; import { ALL_TOOLS, executeTool, ToolContext } from './tools'; const MAX_TURNS = 20; // --------------------------------------------------------------------------- -// Session store — conversation history per session_id +// Session store — one conversation history per session_id // --------------------------------------------------------------------------- interface Session { id: string; - history: Content[]; + history: LLMMessage[]; // OpenAI message format createdAt: string; lastActiveAt: string; } @@ -44,166 +44,180 @@ export function clearSession(sessionId: string) { } // --------------------------------------------------------------------------- -// Orchestrator system prompt — full Vibn context +// Orchestrator system prompt // --------------------------------------------------------------------------- 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. +You run continuously and have full awareness of the Vibn project. You can take autonomous action on behalf of the user. ## 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 +Vibn lets developers build products using AI agents: +- Frontend app (Next.js) at vibnai.com +- Backend API at api.vibnai.com +- Agent runner (this system) at agents.vibnai.com +- Cloud IDE (Theia) at theia.vibnai.com +- Self-hosted Git at git.vibnai.com (user: mark) +- Deployments via Coolify at coolify.vibnai.com (server: 34.19.250.135, Montreal) -## Your capabilities -You have access to tools that give you full project control: +## Your tools -**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 +**Awareness** (understand current state first): +- list_repos — all Git repositories +- list_all_issues — open/in-progress work +- list_all_apps — deployed apps and their status +- get_app_status — health of a specific app - 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 +**Action** (get things done): +- spawn_agent — dispatch Coder, PM, or Marketing agent on a repo +- get_job_status — check a running agent job +- deploy_app — trigger a Coolify deployment +- gitea_create_issue — track work (label agent:coder/pm/marketing to auto-trigger) +- gitea_list_issues / gitea_close_issue — 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 +## Specialist agents you can spawn +- **Coder** — writes code, tests, commits, and pushes +- **PM** — docs, issues, sprint tracking +- **Marketing** — copy, release notes, blog posts ## 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. +1. Use awareness tools first if you need current state. +2. Break the task into concrete steps. +3. Spawn the right agent(s) with specific, detailed instructions. +4. Track and report on results. +5. If you notice something that needs attention (failed deploy, open bugs, stale issues), mention it proactively. -## 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. +## Style +- Direct. No filler. +- Honest about uncertainty. +- When spawning agents, be specific — give them full context, not vague instructions. +- Keep responses concise unless the user needs detail. -## 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`; +## Security +- Never spawn agents on: mark/vibn-frontend, mark/theia-code-os, mark/vibn-agent-runner, mark/vibn-api, mark/master-ai +- Those are protected platform repos — read-only for you, not writable by agents.`; // --------------------------------------------------------------------------- -// Main chat function +// Chat types // --------------------------------------------------------------------------- -export interface ChatMessage { - role: 'user' | 'assistant'; - content: string; -} - export interface ChatResult { reply: string; + reasoning: string | null; sessionId: string; turns: number; toolCalls: string[]; + model: string; } +// --------------------------------------------------------------------------- +// Main orchestrator chat — uses GLM-5 (Tier B) by default +// --------------------------------------------------------------------------- + export async function orchestratorChat( sessionId: string, userMessage: string, - ctx: ToolContext + ctx: ToolContext, + opts?: { + /** Pre-load history from DB — replaces in-memory session history */ + preloadedHistory?: LLMMessage[]; + /** Knowledge items to inject as context at start of conversation */ + knowledgeContext?: string; + } ): Promise { - const apiKey = process.env.GOOGLE_API_KEY; - if (!apiKey) throw new Error('GOOGLE_API_KEY not set'); + const modelId = process.env.ORCHESTRATOR_MODEL ?? 'B'; // Tier B = GLM-5 + const llm = createLLM(modelId, { temperature: 0.3 }); - 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 - })); + // Seed session from DB history if provided and session is fresh + if (opts?.preloadedHistory && opts.preloadedHistory.length > 0 && session.history.length === 0) { + session.history = [...opts.preloadedHistory]; + } - // Add user message to history - session.history.push({ role: 'user', parts: [{ text: userMessage }] }); + const oaiTools = toOAITools(ALL_TOOLS); + + // Append user message + session.history.push({ role: 'user', content: userMessage }); let turn = 0; let finalReply = ''; + let finalReasoning: string | null = null; const toolCallNames: string[] = []; + // Build messages with system prompt prepended + const buildMessages = (): LLMMessage[] => [ + { role: 'system', content: SYSTEM_PROMPT }, + ...session.history + ]; + 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 response = await llm.chat(buildMessages(), oaiTools, 4096); - const candidate = response.candidates?.[0]; - if (!candidate) throw new Error('No response from Gemini'); + // If GLM-5 is still reasoning (content null, finish_reason length) give it more tokens + if (response.content === null && response.tool_calls.length === 0 && response.finish_reason === 'length') { + // Retry with more tokens — model hit max_tokens during reasoning + const retry = await llm.chat(buildMessages(), oaiTools, 8192); + Object.assign(response, retry); + } - const modelContent: Content = { - role: 'model', - parts: candidate.content?.parts || [] + // Record reasoning for the final turn (informational, not stored in history) + if (response.reasoning) finalReasoning = response.reasoning; + + // Build assistant message to add to history + const assistantMsg: LLMMessage = { + role: 'assistant', + content: response.content, + tool_calls: response.tool_calls.length > 0 ? response.tool_calls : undefined }; - session.history.push(modelContent); + session.history.push(assistantMsg); - 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('') ?? ''; + // No tool calls — we have the final answer + if (response.tool_calls.length === 0) { + finalReply = response.content ?? ''; 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); + // Execute each tool call and collect results + for (const tc of response.tool_calls) { + const fnName = tc.function.name; + let fnArgs: Record = {}; + try { fnArgs = JSON.parse(tc.function.arguments || '{}'); } catch { /* bad JSON */ } + + toolCallNames.push(fnName); let result: unknown; try { - result = await executeTool(callName, callArgs, ctx); + result = await executeTool(fnName, fnArgs, ctx); } catch (err) { result = { error: err instanceof Error ? err.message : String(err) }; } - toolResultParts.push({ - functionResponse: { name: callName, response: { result } } + // Add tool result to history + session.history.push({ + role: 'tool', + tool_call_id: tc.id, + name: fnName, + content: typeof result === 'string' ? result : JSON.stringify(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.'; + finalReply = 'Hit the turn limit. Try a more specific request.'; } - return { reply: finalReply, sessionId, turns: turn, toolCalls: toolCallNames }; + return { + reply: finalReply, + reasoning: finalReasoning, + sessionId, + turns: turn, + toolCalls: toolCallNames, + model: llm.modelId, + history: session.history.slice(-40), + memoryUpdates: ctx.memoryUpdates + }; } diff --git a/src/server.ts b/src/server.ts index 52c744c..040642e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -10,6 +10,15 @@ import { AGENTS } from './agents'; import { ToolContext } from './tools'; import { orchestratorChat, listSessions, clearSession } from './orchestrator'; +// Protected Vibn platform repos — agents cannot clone or work in these workspaces +const PROTECTED_GITEA_REPOS = new Set([ + 'mark/vibn-frontend', + 'mark/theia-code-os', + 'mark/vibn-agent-runner', + 'mark/vibn-api', + 'mark/master-ai', +]); + const app = express(); app.use(cors()); @@ -33,6 +42,12 @@ function ensureWorkspace(repo?: string): string { fs.mkdirSync(dir, { recursive: true }); return dir; } + if (PROTECTED_GITEA_REPOS.has(repo)) { + throw new Error( + `SECURITY: Repo "${repo}" is a protected Vibn platform repo. ` + + `Agents cannot clone or work in this workspace.` + ); + } const dir = path.join(base, repo.replace('/', '_')); const gitea = { apiUrl: process.env.GITEA_API_URL || '', @@ -67,7 +82,8 @@ function buildContext(repo?: string): ToolContext { coolify: { apiUrl: process.env.COOLIFY_API_URL || '', apiToken: process.env.COOLIFY_API_TOKEN || '' - } + }, + memoryUpdates: [] }; } diff --git a/src/tools.ts b/src/tools.ts index 5ea44c7..97524b2 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -6,10 +6,65 @@ import { Minimatch } from 'minimatch'; const execAsync = util.promisify(cp.exec); +// ============================================================================= +// SECURITY GUARDRAILS — Protected VIBN Platform Resources +// +// These repos and Coolify resources belong to the Vibn platform itself. +// Agents must never be allowed to push code or trigger deployments here. +// Read-only operations (list, read file, get status) are still permitted +// so agents can observe the platform state, but all mutations are blocked. +// ============================================================================= + +/** Gitea repos that agents can NEVER push to, commit to, or write issues on. */ +const PROTECTED_GITEA_REPOS = new Set([ + 'mark/vibn-frontend', + 'mark/theia-code-os', + 'mark/vibn-agent-runner', + 'mark/vibn-api', + 'mark/master-ai', +]); + +/** Coolify project UUID for the VIBN platform — agents cannot deploy here. */ +const PROTECTED_COOLIFY_PROJECT = 'f4owwggokksgw0ogo0844os0'; + +/** + * Specific Coolify app UUIDs that must never be deployed by an agent. + * This is a belt-and-suspenders check in case the project UUID filter is bypassed. + */ +const PROTECTED_COOLIFY_APPS = new Set([ + 'y4cscsc8s08c8808go0448s0', // vibn-frontend + 'kggs4ogckc0w8ggwkkk88kck', // vibn-postgres + 'o4wwck0g0c04wgoo4g4s0004', // gitea +]); + +function assertGiteaWritable(repo: string): void { + if (PROTECTED_GITEA_REPOS.has(repo)) { + throw new Error( + `SECURITY: Repo "${repo}" is a protected Vibn platform repo. ` + + `Agents cannot push code or modify issues in this repository.` + ); + } +} + +function assertCoolifyDeployable(appUuid: string): void { + if (PROTECTED_COOLIFY_APPS.has(appUuid)) { + throw new Error( + `SECURITY: App "${appUuid}" is a protected Vibn platform application. ` + + `Agents cannot trigger deployments for this application.` + ); + } +} + // --------------------------------------------------------------------------- // Context passed to every tool call — workspace root + credentials // --------------------------------------------------------------------------- +export interface MemoryUpdate { + key: string; + type: string; // e.g. "tech_stack" | "decision" | "feature" | "goal" | "constraint" | "note" + value: string; +} + export interface ToolContext { workspaceRoot: string; gitea: { @@ -21,6 +76,8 @@ export interface ToolContext { apiUrl: string; apiToken: string; }; + /** Accumulated memory updates from save_memory tool calls in this turn */ + memoryUpdates: MemoryUpdate[]; } // --------------------------------------------------------------------------- @@ -289,6 +346,23 @@ export const ALL_TOOLS: ToolDefinition[] = [ }, required: ['app_name'] } + }, + { + name: 'save_memory', + description: 'Persist an important fact about this project to long-term memory. Use this to save decisions, tech stack choices, feature descriptions, constraints, or goals so they are remembered across conversations.', + parameters: { + type: 'object', + properties: { + key: { type: 'string', description: 'Short unique label (e.g. "primary_language", "auth_strategy", "deploy_target")' }, + type: { + type: 'string', + enum: ['tech_stack', 'decision', 'feature', 'goal', 'constraint', 'note'], + description: 'Category of the memory item' + }, + value: { type: 'string', description: 'The fact to remember (1-3 sentences)' } + }, + required: ['key', 'type', 'value'] + } } ]; @@ -447,6 +521,19 @@ async function gitCommitAndPush(message: string, ctx: ToolContext): Promise { - return coolifyFetch('/projects', ctx); + const projects = await coolifyFetch('/projects', ctx) as any[]; + if (!Array.isArray(projects)) return projects; + // Filter out the protected VIBN project entirely — agents don't need to see it + return projects.filter((p: any) => p.uuid !== PROTECTED_COOLIFY_PROJECT); } async function coolifyListApplications(projectUuid: string, ctx: ToolContext): Promise { @@ -503,6 +593,15 @@ async function coolifyListApplications(projectUuid: string, ctx: ToolContext): P } async function coolifyDeploy(appUuid: string, ctx: ToolContext): Promise { + assertCoolifyDeployable(appUuid); + // Also check the app belongs to the right project + const apps = await coolifyFetch('/applications', ctx) as any[]; + if (Array.isArray(apps)) { + const app = apps.find((a: any) => a.uuid === appUuid); + if (app?.project_uuid === PROTECTED_COOLIFY_PROJECT) { + return { error: `SECURITY: App "${appUuid}" belongs to the protected Vibn project. Agents cannot deploy platform apps.` }; + } + } return coolifyFetch(`/applications/${appUuid}/deploy`, ctx, 'POST'); } @@ -529,6 +628,7 @@ async function giteaFetch(path: string, ctx: ToolContext, method = 'GET', body?: } async function giteaCreateIssue(repo: string, title: string, body: string, labels: string[] | undefined, ctx: ToolContext): Promise { + assertGiteaWritable(repo); return giteaFetch(`/repos/${repo}/issues`, ctx, 'POST', { title, body, labels }); } @@ -537,6 +637,7 @@ async function giteaListIssues(repo: string, state: string, ctx: ToolContext): P } async function giteaCloseIssue(repo: string, issueNumber: number, ctx: ToolContext): Promise { + assertGiteaWritable(repo); return giteaFetch(`/repos/${repo}/issues/${issueNumber}`, ctx, 'PATCH', { state: 'closed' }); } @@ -569,21 +670,27 @@ async function listRepos(ctx: ToolContext): Promise { 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 - })); + return (data.data || []) + // Hide protected platform repos from agent's view entirely + .filter((r: any) => !PROTECTED_GITEA_REPOS.has(r.full_name)) + .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) { + if (PROTECTED_GITEA_REPOS.has(repo)) { + return { error: `SECURITY: "${repo}" is a protected Vibn platform repo. Agents cannot access its issues.` }; + } return giteaFetch(`/repos/${repo}/issues?state=${state}&limit=20`, ctx); } - // Fetch across all repos + // Fetch across all non-protected repos const repos = await listRepos(ctx) as any[]; const allIssues: unknown[] = []; for (const r of repos.slice(0, 10)) { @@ -605,14 +712,17 @@ async function listAllIssues(repo: string | undefined, state: string, ctx: ToolC 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 - })); + return apps + // Filter out apps that belong to the protected VIBN project + .filter((a: any) => a.project_uuid !== PROTECTED_COOLIFY_PROJECT && !PROTECTED_COOLIFY_APPS.has(a.uuid)) + .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 { @@ -622,6 +732,9 @@ async function getAppStatus(appName: string, ctx: ToolContext): Promise a.name?.toLowerCase() === appName.toLowerCase() || a.uuid === appName ); if (!app) return { error: `App "${appName}" not found` }; + if (PROTECTED_COOLIFY_APPS.has(app.uuid) || app.project_uuid === PROTECTED_COOLIFY_PROJECT) { + return { error: `SECURITY: "${appName}" is a protected Vibn platform app. Status is not exposed to agents.` }; + } 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 }; } @@ -659,6 +772,11 @@ async function getJobStatus(jobId: string): Promise { } } +function saveMemory(key: string, type: string, value: string, ctx: ToolContext): unknown { + ctx.memoryUpdates.push({ key, type, value }); + return { saved: true, key, type }; +} + async function deployApp(appName: string, ctx: ToolContext): Promise { const apps = await coolifyFetch('/applications', ctx) as any[]; if (!Array.isArray(apps)) return apps; @@ -666,6 +784,13 @@ async function deployApp(appName: string, ctx: ToolContext): Promise { a.name?.toLowerCase() === appName.toLowerCase() || a.uuid === appName ); if (!app) return { error: `App "${appName}" not found` }; + // Block deployment to protected VIBN platform apps + if (PROTECTED_COOLIFY_APPS.has(app.uuid) || app.project_uuid === PROTECTED_COOLIFY_PROJECT) { + return { + error: `SECURITY: "${appName}" is a protected Vibn platform application. ` + + `Agents can only deploy user project apps, not platform infrastructure.` + }; + } const result = await fetch(`${ctx.coolify.apiUrl}/api/v1/deploy?uuid=${app.uuid}&force=false`, { headers: { 'Authorization': `Bearer ${ctx.coolify.apiToken}` } });