init: vibn-agent-runner — Gemini autonomous agent backend

Made-with: Cursor
This commit is contained in:
2026-02-26 14:50:20 -08:00
commit 8870f2b1e0
2519 changed files with 973799 additions and 0 deletions

16
dist/agent-runner.d.ts vendored Normal file
View File

@@ -0,0 +1,16 @@
import { AgentConfig } from './agents';
import { ToolContext } from './tools';
import { Job } from './job-store';
export interface RunResult {
finalText: string;
toolCallCount: number;
turns: number;
}
/**
* Core Gemini agent loop.
*
* Sends the task to Gemini with the agent's system prompt and tools,
* then loops: execute tool calls → send results back → repeat until
* the model stops calling tools or MAX_TURNS is reached.
*/
export declare function runAgent(job: Job, config: AgentConfig, task: string, ctx: ToolContext): Promise<RunResult>;

117
dist/agent-runner.js vendored Normal file
View File

@@ -0,0 +1,117 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.runAgent = runAgent;
const genai_1 = require("@google/genai");
const tools_1 = require("./tools");
const job_store_1 = require("./job-store");
const MAX_TURNS = 40; // safety cap — prevents infinite loops
/**
* Core Gemini agent loop.
*
* Sends the task to Gemini with the agent's system prompt and tools,
* then loops: execute tool calls → send results back → repeat until
* the model stops calling tools or MAX_TURNS is reached.
*/
async function runAgent(job, config, task, ctx) {
const apiKey = process.env.GOOGLE_API_KEY;
if (!apiKey) {
throw new Error('GOOGLE_API_KEY environment variable is not set');
}
const genai = new genai_1.GoogleGenAI({ apiKey });
// Build Gemini function declarations from our tool definitions
const functionDeclarations = config.tools.map(tool => ({
name: tool.name,
description: tool.description,
parameters: tool.parameters
}));
const tools = functionDeclarations.length > 0
? [{ functionDeclarations }]
: [];
const model = genai.models;
// Build conversation history
const history = [];
// Initial user message
let currentMessage = {
role: 'user',
parts: [{ text: task }]
};
let toolCallCount = 0;
let turn = 0;
let finalText = '';
(0, job_store_1.updateJob)(job.id, { status: 'running', progress: `Starting ${config.name} agent...` });
while (turn < MAX_TURNS) {
turn++;
// Add current message to history
history.push(currentMessage);
// Call Gemini
const response = await model.generateContent({
model: config.model || 'gemini-2.0-flash',
contents: history,
config: {
systemInstruction: config.systemPrompt,
tools: tools.length > 0 ? tools : undefined,
temperature: 0.2,
maxOutputTokens: 8192
}
});
const candidate = response.candidates?.[0];
if (!candidate) {
throw new Error('No response from Gemini');
}
// Add model response to history
const modelContent = {
role: 'model',
parts: candidate.content?.parts || []
};
history.push(modelContent);
// Extract function calls from the response
const functionCalls = candidate.content?.parts?.filter(p => p.functionCall) ?? [];
if (functionCalls.length === 0) {
// No tool calls — the agent is done
finalText = candidate.content?.parts
?.filter(p => p.text)
.map(p => p.text)
.join('') ?? '';
break;
}
// Execute all tool calls
const toolResultParts = [];
for (const part of functionCalls) {
const call = part.functionCall;
const callName = call.name ?? 'unknown';
const callArgs = (call.args ?? {});
toolCallCount++;
(0, job_store_1.updateJob)(job.id, {
progress: `Turn ${turn}: calling ${callName}...`,
toolCalls: [...(job.toolCalls || []), {
turn,
tool: callName,
args: callArgs,
timestamp: new Date().toISOString()
}]
});
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 }
}
});
}
// Next turn: send tool results back to the model
currentMessage = {
role: 'user',
parts: toolResultParts
};
}
if (turn >= MAX_TURNS && !finalText) {
finalText = `Agent reached the ${MAX_TURNS}-turn safety limit. Last tool call count: ${toolCallCount}.`;
}
return { finalText, toolCallCount, turns: turn };
}

9
dist/agents.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
import { ToolDefinition } from './tools';
export interface AgentConfig {
name: string;
description: string;
model: string;
systemPrompt: string;
tools: ToolDefinition[];
}
export declare const AGENTS: Record<string, AgentConfig>;

116
dist/agents.js vendored Normal file
View File

@@ -0,0 +1,116 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.AGENTS = void 0;
const tools_1 = require("./tools");
const FILE_TOOLS = ['read_file', 'write_file', 'replace_in_file', 'list_directory', 'find_files', 'search_code'];
const SHELL_TOOLS = ['execute_command'];
const GIT_TOOLS = ['git_commit_and_push'];
const COOLIFY_TOOLS = ['coolify_list_projects', 'coolify_list_applications', 'coolify_deploy', 'coolify_get_logs'];
const GITEA_TOOLS = ['gitea_create_issue', 'gitea_list_issues', 'gitea_close_issue'];
const SPAWN_TOOL = ['spawn_agent'];
function pick(names) {
return tools_1.ALL_TOOLS.filter(t => names.includes(t.name));
}
// ---------------------------------------------------------------------------
// Agent definitions
// ---------------------------------------------------------------------------
exports.AGENTS = {
Orchestrator: {
name: 'Orchestrator',
description: 'Master coordinator that breaks down high-level goals and delegates to specialist agents',
model: 'gemini-2.5-flash',
systemPrompt: `You are the Orchestrator for Vibn, an autonomous AI system for software development.
Your role is to:
1. Understand the high-level goal provided in the task.
2. Break it down into concrete sub-tasks.
3. Delegate sub-tasks to the appropriate specialist agents using the spawn_agent tool.
4. Use Gitea to track progress: create an issue at the start, close it when done.
5. Summarize what was done when complete.
Available specialist agents and when to use them:
- **Coder**: Any code changes — features, bug fixes, refactors, tests.
- **PM**: Project management — issue triage, sprint planning, documentation updates.
- **Marketing**: Content and copy — blog posts, landing page copy, release notes.
Rules:
- Always create a Gitea issue first to track the work.
- Delegate to ONE agent at a time unless tasks are fully independent.
- Check back on progress by listing issues.
- Never try to write code yourself — delegate to Coder.
- Be concise in your task descriptions when spawning agents.`,
tools: pick([...GITEA_TOOLS, ...SPAWN_TOOL, ...COOLIFY_TOOLS])
},
Coder: {
name: 'Coder',
description: 'Senior software engineer — writes, edits, and tests code. Commits and pushes when done.',
model: 'gemini-2.5-flash',
systemPrompt: `You are an expert senior software engineer working autonomously on a git repository.
Your job is to complete the coding task given to you. Follow these rules:
**Workflow:**
1. Start by exploring the codebase: list_directory, find_files, read_file to understand structure.
2. Search for relevant code: search_code to find existing patterns.
3. Plan your changes before making them.
4. Read every file BEFORE editing it.
5. Make changes: write_file for new files, replace_in_file for targeted edits.
6. Run tests or lint if applicable: execute_command.
7. Commit and push when the task is complete: git_commit_and_push.
**Code quality rules:**
- Match existing code style exactly.
- Never leave TODO comments — implement or skip.
- Write complete files, not partial snippets.
- If tests exist, run them and fix failures before committing.
- Commit message should be concise and in imperative mood (e.g. "add user authentication").
**Safety rules:**
- Never delete files unless explicitly instructed.
- Never modify .env files or credentials.
- Never commit secrets or API keys.
Be methodical. Read before you write. Test before you commit.`,
tools: pick([...FILE_TOOLS, ...SHELL_TOOLS, ...GIT_TOOLS])
},
PM: {
name: 'PM',
description: 'Product manager — manages Gitea issues, writes documentation, tracks project health',
model: 'gemini-2.5-flash',
systemPrompt: `You are an autonomous Product Manager for a software project hosted on Gitea.
Your responsibilities:
1. Create, update, and close Gitea issues to track work.
2. Write and update documentation files in the repository.
3. Summarize project state and create reports.
4. Prioritize and triage bugs/features based on impact.
When writing documentation:
- Be clear and concise.
- Use markdown formatting.
- Focus on what users and developers need to know.
- Keep docs up to date with the actual codebase state.
Always commit documentation updates after writing them.`,
tools: pick([...GITEA_TOOLS, ...FILE_TOOLS, ...GIT_TOOLS])
},
Marketing: {
name: 'Marketing',
description: 'Marketing specialist — writes copy, blog posts, release notes, and landing page content',
model: 'gemini-2.5-flash',
systemPrompt: `You are an autonomous Marketing specialist for a SaaS product called Vibn.
Vibn is a cloud-based AI-powered development environment. It helps development teams build faster with AI agents that can write code, manage projects, and deploy automatically.
Your responsibilities:
1. Write compelling marketing copy for landing pages, email campaigns, and social media.
2. Write technical blog posts that explain features in an accessible way.
3. Write release notes that highlight user-facing value.
4. Ensure all copy is on-brand: professional, clear, forward-thinking, and developer-friendly.
Brand voice: Smart, confident, practical. No hype. No jargon. Show don't tell.
When writing content, create actual files in the repository (e.g. blog/2026-02-release.md) and commit them.`,
tools: pick([...FILE_TOOLS, ...GIT_TOOLS])
}
};

24
dist/job-store.d.ts vendored Normal file
View File

@@ -0,0 +1,24 @@
export type JobStatus = 'queued' | 'running' | 'completed' | 'failed';
export interface ToolCallRecord {
turn: number;
tool: string;
args: unknown;
timestamp: string;
}
export interface Job {
id: string;
agent: string;
task: string;
repo?: string;
status: JobStatus;
progress: string;
toolCalls: ToolCallRecord[];
result?: string;
error?: string;
createdAt: string;
updatedAt: string;
}
export declare function createJob(agent: string, task: string, repo?: string): Job;
export declare function getJob(id: string): Job | undefined;
export declare function updateJob(id: string, updates: Partial<Job>): Job | undefined;
export declare function listJobs(limit?: number): Job[];

43
dist/job-store.js vendored Normal file
View File

@@ -0,0 +1,43 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.createJob = createJob;
exports.getJob = getJob;
exports.updateJob = updateJob;
exports.listJobs = listJobs;
const uuid_1 = require("uuid");
// ---------------------------------------------------------------------------
// In-memory store (swap for Redis/DB if scaling horizontally)
// ---------------------------------------------------------------------------
const store = new Map();
function createJob(agent, task, repo) {
const job = {
id: (0, uuid_1.v4)(),
agent,
task,
repo,
status: 'queued',
progress: 'Job queued',
toolCalls: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
};
store.set(job.id, job);
return job;
}
function getJob(id) {
return store.get(id);
}
function updateJob(id, updates) {
const job = store.get(id);
if (!job)
return undefined;
const updated = { ...job, ...updates, id, updatedAt: new Date().toISOString() };
store.set(id, updated);
return updated;
}
function listJobs(limit = 50) {
const all = Array.from(store.values());
return all
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
.slice(0, limit);
}

1
dist/server.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
export {};

178
dist/server.js vendored Normal file
View File

@@ -0,0 +1,178 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const express_1 = __importDefault(require("express"));
const cors_1 = __importDefault(require("cors"));
const job_store_1 = require("./job-store");
const agent_runner_1 = require("./agent-runner");
const agents_1 = require("./agents");
const app = (0, express_1.default)();
app.use((0, cors_1.default)());
app.use(express_1.default.json());
const PORT = process.env.PORT || 3333;
// ---------------------------------------------------------------------------
// Build ToolContext from environment variables
// ---------------------------------------------------------------------------
function buildContext(repo) {
const workspaceRoot = repo
? `${process.env.WORKSPACE_BASE || '/workspaces'}/${repo.replace('/', '_')}`
: (process.env.WORKSPACE_BASE || '/workspaces/default');
return {
workspaceRoot,
gitea: {
apiUrl: process.env.GITEA_API_URL || '',
apiToken: process.env.GITEA_API_TOKEN || '',
username: process.env.GITEA_USERNAME || ''
},
coolify: {
apiUrl: process.env.COOLIFY_API_URL || '',
apiToken: process.env.COOLIFY_API_TOKEN || ''
}
};
}
// ---------------------------------------------------------------------------
// Routes
// ---------------------------------------------------------------------------
// Health check
app.get('/health', (_req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// List available agents
app.get('/api/agents', (_req, res) => {
const agents = Object.values(agents_1.AGENTS).map(a => ({
name: a.name,
description: a.description,
tools: a.tools.map(t => t.name)
}));
res.json(agents);
});
// Submit a new job
app.post('/api/agent/run', async (req, res) => {
const { agent: agentName, task, repo } = req.body;
if (!agentName || !task) {
res.status(400).json({ error: '"agent" and "task" are required' });
return;
}
const agentConfig = agents_1.AGENTS[agentName];
if (!agentConfig) {
const available = Object.keys(agents_1.AGENTS).join(', ');
res.status(400).json({ error: `Unknown agent "${agentName}". Available: ${available}` });
return;
}
const job = (0, job_store_1.createJob)(agentName, task, repo);
res.status(202).json({ jobId: job.id, status: job.status });
// Run agent asynchronously
const ctx = buildContext(repo);
(0, agent_runner_1.runAgent)(job, agentConfig, task, ctx)
.then(result => {
(0, job_store_1.updateJob)(job.id, {
status: 'completed',
result: result.finalText,
progress: `Done — ${result.turns} turns, ${result.toolCallCount} tool calls`
});
})
.catch(err => {
(0, job_store_1.updateJob)(job.id, {
status: 'failed',
error: err instanceof Error ? err.message : String(err),
progress: 'Agent failed'
});
});
});
// Check job status
app.get('/api/jobs/:id', (req, res) => {
const job = (0, job_store_1.getJob)(req.params.id);
if (!job) {
res.status(404).json({ error: 'Job not found' });
return;
}
res.json(job);
});
// List recent jobs
app.get('/api/jobs', (req, res) => {
const limit = parseInt(req.query.limit || '20', 10);
res.json((0, job_store_1.listJobs)(limit));
});
// Gitea webhook endpoint — triggers agent from a push/issue event
app.post('/webhook/gitea', (req, res) => {
const event = req.headers['x-gitea-event'];
const body = req.body;
// Verify secret if configured
const webhookSecret = process.env.WEBHOOK_SECRET;
if (webhookSecret) {
const sig = req.headers['x-gitea-signature'];
if (!sig || sig !== webhookSecret) {
res.status(401).json({ error: 'Invalid webhook signature' });
return;
}
}
let task = null;
let agentName = 'Coder';
let repo;
if (event === 'issues' && body.action === 'opened') {
const issue = body.issue;
repo = `${body.repository?.owner?.login}/${body.repository?.name}`;
const labels = (issue.labels || []).map((l) => l.name);
if (labels.includes('agent:pm')) {
agentName = 'PM';
}
else if (labels.includes('agent:marketing')) {
agentName = 'Marketing';
}
else {
agentName = 'Coder';
}
task = `Resolve this GitHub issue:\n\nTitle: ${issue.title}\n\nDescription:\n${issue.body || '(no description)'}`;
}
else if (event === 'push') {
// Optionally trigger on push — useful for CI-style automation
res.json({ ignored: true, reason: 'push events not auto-processed' });
return;
}
else {
res.json({ ignored: true, event });
return;
}
if (!task) {
res.json({ ignored: true });
return;
}
const agentConfig = agents_1.AGENTS[agentName];
const job = (0, job_store_1.createJob)(agentName, task, repo);
res.status(202).json({ jobId: job.id, agent: agentName, event });
const ctx = buildContext(repo);
(0, agent_runner_1.runAgent)(job, agentConfig, task, ctx)
.then(result => {
(0, job_store_1.updateJob)(job.id, {
status: 'completed',
result: result.finalText,
progress: `Done — ${result.turns} turns, ${result.toolCallCount} tool calls`
});
})
.catch(err => {
(0, job_store_1.updateJob)(job.id, {
status: 'failed',
error: err instanceof Error ? err.message : String(err),
progress: 'Agent failed'
});
});
});
// ---------------------------------------------------------------------------
// Error handler
// ---------------------------------------------------------------------------
app.use((err, _req, res, _next) => {
console.error(err.stack);
res.status(500).json({ error: err.message });
});
// ---------------------------------------------------------------------------
// Start
// ---------------------------------------------------------------------------
app.listen(PORT, () => {
console.log(`AgentRunner listening on port ${PORT}`);
console.log(`Agents available: ${Object.keys(agents_1.AGENTS).join(', ')}`);
if (!process.env.GOOGLE_API_KEY) {
console.warn('WARNING: GOOGLE_API_KEY is not set — agents will fail');
}
});

19
dist/tools.d.ts vendored Normal file
View File

@@ -0,0 +1,19 @@
export interface ToolContext {
workspaceRoot: string;
gitea: {
apiUrl: string;
apiToken: string;
username: string;
};
coolify: {
apiUrl: string;
apiToken: string;
};
}
export interface ToolDefinition {
name: string;
description: string;
parameters: Record<string, unknown>;
}
export declare const ALL_TOOLS: ToolDefinition[];
export declare function executeTool(name: string, args: Record<string, unknown>, ctx: ToolContext): Promise<unknown>;

469
dist/tools.js vendored Normal file
View File

@@ -0,0 +1,469 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.ALL_TOOLS = void 0;
exports.executeTool = executeTool;
const fs = __importStar(require("fs"));
const path = __importStar(require("path"));
const cp = __importStar(require("child_process"));
const util = __importStar(require("util"));
const minimatch_1 = require("minimatch");
const execAsync = util.promisify(cp.exec);
exports.ALL_TOOLS = [
{
name: 'read_file',
description: 'Read the complete content of a file in the workspace. Always read before editing.',
parameters: {
type: 'object',
properties: {
path: { type: 'string', description: 'Relative path from workspace root (e.g. "src/index.ts")' }
},
required: ['path']
}
},
{
name: 'write_file',
description: 'Write complete content to a file. Creates parent directories if needed. Overwrites existing files.',
parameters: {
type: 'object',
properties: {
path: { type: 'string', description: 'Relative path from workspace root' },
content: { type: 'string', description: 'Complete new file content' }
},
required: ['path', 'content']
}
},
{
name: 'replace_in_file',
description: 'Replace an exact string in a file. The old_content must match character-for-character. Read the file first.',
parameters: {
type: 'object',
properties: {
path: { type: 'string', description: 'Relative path from workspace root' },
old_content: { type: 'string', description: 'Exact text to replace' },
new_content: { type: 'string', description: 'Replacement text' }
},
required: ['path', 'old_content', 'new_content']
}
},
{
name: 'list_directory',
description: 'List files and subdirectories in a directory. Directories have trailing "/".',
parameters: {
type: 'object',
properties: {
path: { type: 'string', description: 'Relative path from workspace root. Use "." for root.' }
},
required: ['path']
}
},
{
name: 'find_files',
description: 'Find files matching a glob pattern in the workspace. Returns up to 200 relative paths.',
parameters: {
type: 'object',
properties: {
pattern: { type: 'string', description: 'Glob pattern e.g. "**/*.ts", "src/**/*.test.js"' }
},
required: ['pattern']
}
},
{
name: 'search_code',
description: 'Search file contents for a string or regex pattern. Returns file path, line number, and matching line.',
parameters: {
type: 'object',
properties: {
query: { type: 'string', description: 'Search term or regex' },
file_extensions: {
type: 'array',
items: { type: 'string' },
description: 'Optional: limit to these extensions e.g. ["ts","js"]'
}
},
required: ['query']
}
},
{
name: 'execute_command',
description: 'Run a shell command in the workspace and return stdout + stderr. 120s timeout. Use for: npm install, npm test, git status, building, etc.',
parameters: {
type: 'object',
properties: {
command: { type: 'string', description: 'Shell command to run' },
working_directory: { type: 'string', description: 'Optional: relative subdirectory to run in' }
},
required: ['command']
}
},
{
name: 'git_commit_and_push',
description: 'Stage all changes, commit with a message, and push to the remote. Call this when work is complete.',
parameters: {
type: 'object',
properties: {
message: { type: 'string', description: 'Commit message describing the changes made' }
},
required: ['message']
}
},
{
name: 'coolify_list_projects',
description: 'List all projects in the Coolify instance. Returns project names and UUIDs.',
parameters: { type: 'object', properties: {} }
},
{
name: 'coolify_list_applications',
description: 'List applications in a Coolify project.',
parameters: {
type: 'object',
properties: {
project_uuid: { type: 'string', description: 'Project UUID from coolify_list_projects' }
},
required: ['project_uuid']
}
},
{
name: 'coolify_deploy',
description: 'Trigger a deployment for a Coolify application.',
parameters: {
type: 'object',
properties: {
application_uuid: { type: 'string', description: 'Application UUID to deploy' }
},
required: ['application_uuid']
}
},
{
name: 'coolify_get_logs',
description: 'Get recent deployment logs for a Coolify application.',
parameters: {
type: 'object',
properties: {
application_uuid: { type: 'string', description: 'Application UUID' }
},
required: ['application_uuid']
}
},
{
name: 'gitea_create_issue',
description: 'Create a new issue in a Gitea repository.',
parameters: {
type: 'object',
properties: {
repo: { type: 'string', description: 'Repository in "owner/name" format' },
title: { type: 'string', description: 'Issue title' },
body: { type: 'string', description: 'Issue body (markdown)' },
labels: { type: 'array', items: { type: 'string' }, description: 'Optional label names' }
},
required: ['repo', 'title', 'body']
}
},
{
name: 'gitea_list_issues',
description: 'List open issues in a Gitea repository.',
parameters: {
type: 'object',
properties: {
repo: { type: 'string', description: 'Repository in "owner/name" format' },
state: { type: 'string', description: '"open", "closed", or "all". Default: "open"' }
},
required: ['repo']
}
},
{
name: 'gitea_close_issue',
description: 'Close an issue in a Gitea repository.',
parameters: {
type: 'object',
properties: {
repo: { type: 'string', description: 'Repository in "owner/name" format' },
issue_number: { type: 'number', description: 'Issue number to close' }
},
required: ['repo', 'issue_number']
}
},
{
name: 'spawn_agent',
description: 'Dispatch a sub-agent job to run in the background. Returns a job ID. Use this to delegate specialized work to Coder, PM, or Marketing agents.',
parameters: {
type: 'object',
properties: {
agent: { type: 'string', description: '"Coder", "PM", or "Marketing"' },
task: { type: 'string', description: 'Detailed task description for the agent' },
repo: { type: 'string', description: 'Gitea repo in "owner/name" format the agent should work on' }
},
required: ['agent', 'task', 'repo']
}
}
];
// ---------------------------------------------------------------------------
// Tool executor — routes call.name → implementation
// ---------------------------------------------------------------------------
async function executeTool(name, args, ctx) {
switch (name) {
case 'read_file': return readFile(String(args.path), ctx);
case 'write_file': return writeFile(String(args.path), String(args.content), ctx);
case 'replace_in_file': return replaceInFile(String(args.path), String(args.old_content), String(args.new_content), ctx);
case 'list_directory': return listDirectory(String(args.path), ctx);
case 'find_files': return findFiles(String(args.pattern), ctx);
case 'search_code': return searchCode(String(args.query), args.file_extensions, ctx);
case 'execute_command': return executeCommand(String(args.command), args.working_directory, ctx);
case 'git_commit_and_push': return gitCommitAndPush(String(args.message), ctx);
case 'coolify_list_projects': return coolifyListProjects(ctx);
case 'coolify_list_applications': return coolifyListApplications(String(args.project_uuid), ctx);
case 'coolify_deploy': return coolifyDeploy(String(args.application_uuid), ctx);
case 'coolify_get_logs': return coolifyGetLogs(String(args.application_uuid), ctx);
case 'gitea_create_issue': return giteaCreateIssue(String(args.repo), String(args.title), String(args.body), args.labels, 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);
default:
return { error: `Unknown tool: ${name}` };
}
}
// ---------------------------------------------------------------------------
// Implementations
// ---------------------------------------------------------------------------
const EXCLUDED = new Set(['node_modules', '.git', 'dist', 'build', 'lib', '.cache', 'coverage']);
function safeResolve(root, rel) {
const resolved = path.resolve(root, rel);
if (!resolved.startsWith(path.resolve(root))) {
throw new Error(`Path escapes workspace: ${rel}`);
}
return resolved;
}
async function readFile(filePath, ctx) {
const abs = safeResolve(ctx.workspaceRoot, filePath);
try {
return fs.readFileSync(abs, 'utf8');
}
catch {
return JSON.stringify({ error: `File not found: ${filePath}` });
}
}
async function writeFile(filePath, content, ctx) {
const abs = safeResolve(ctx.workspaceRoot, filePath);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, content, 'utf8');
return { success: true, path: filePath, bytes: Buffer.byteLength(content) };
}
async function replaceInFile(filePath, oldContent, newContent, ctx) {
const abs = safeResolve(ctx.workspaceRoot, filePath);
const current = fs.readFileSync(abs, 'utf8');
if (!current.includes(oldContent)) {
return { error: 'old_content not found in file. Read the file again to get the current content.' };
}
fs.writeFileSync(abs, current.replace(oldContent, newContent), 'utf8');
return { success: true, path: filePath };
}
async function listDirectory(dirPath, ctx) {
const abs = safeResolve(ctx.workspaceRoot, dirPath);
try {
const entries = fs.readdirSync(abs, { withFileTypes: true });
return entries
.filter(e => !EXCLUDED.has(e.name))
.map(e => e.isDirectory() ? `${e.name}/` : e.name);
}
catch {
return { error: `Directory not found: ${dirPath}` };
}
}
async function findFiles(pattern, ctx) {
const matcher = new minimatch_1.Minimatch(pattern, { dot: false });
const results = [];
function walk(dir) {
if (results.length >= 200)
return;
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
}
catch {
return;
}
for (const e of entries) {
if (EXCLUDED.has(e.name))
continue;
const abs = path.join(dir, e.name);
const rel = path.relative(ctx.workspaceRoot, abs).split(path.sep).join('/');
if (e.isDirectory()) {
walk(abs);
}
else if (matcher.match(rel)) {
results.push(rel);
}
}
}
walk(ctx.workspaceRoot);
return { files: results, truncated: results.length >= 200 };
}
async function searchCode(query, extensions, ctx) {
const globPatterns = extensions?.map(e => `*.${e}`) || [];
const args = ['--line-number', '--no-heading', '--color=never', '--max-count=30'];
for (const ex of EXCLUDED) {
args.push('--glob', `!${ex}`);
}
if (globPatterns.length > 0) {
for (const g of globPatterns)
args.push('--glob', g);
}
args.push('--fixed-strings', query, ctx.workspaceRoot);
try {
const { stdout } = await execAsync(`rg ${args.map(a => `'${a}'`).join(' ')}`, { cwd: ctx.workspaceRoot, timeout: 15000 });
const lines = stdout.trim().split('\n').filter(Boolean).map(line => {
const m = line.match(/^(.+?):(\d+):(.*)$/);
if (!m)
return null;
return { file: path.relative(ctx.workspaceRoot, m[1]).split(path.sep).join('/'), line: parseInt(m[2]), content: m[3].trim() };
}).filter(Boolean);
return lines;
}
catch (err) {
if (err.code === 1)
return []; // ripgrep exit 1 = no matches
return { error: `Search failed: ${err.message}` };
}
}
async function executeCommand(command, workingDir, ctx) {
const BLOCKED = ['rm -rf /', 'mkfs', ':(){:|:&};:'];
if (BLOCKED.some(b => command.includes(b))) {
return { error: 'Command blocked for safety.' };
}
const cwd = workingDir ? safeResolve(ctx.workspaceRoot, workingDir) : ctx.workspaceRoot;
try {
const { stdout, stderr } = await execAsync(command, { cwd, timeout: 120000, maxBuffer: 1024 * 1024 });
return { exitCode: 0, stdout: stdout.trim(), stderr: stderr.trim() };
}
catch (err) {
return { exitCode: err.code, stdout: (err.stdout || '').trim(), stderr: (err.stderr || '').trim(), error: err.message };
}
}
async function gitCommitAndPush(message, ctx) {
const cwd = ctx.workspaceRoot;
const { apiUrl, apiToken, username } = ctx.gitea;
try {
await execAsync('git add -A', { cwd });
await execAsync(`git commit -m "${message.replace(/"/g, '\\"')}"`, { cwd });
// Ensure remote has credentials
const remoteUrl = (await execAsync('git remote get-url origin', { cwd })).stdout.trim();
const authedUrl = remoteUrl.startsWith('https://')
? remoteUrl.replace('https://', `https://${username}:${apiToken}@`)
: remoteUrl;
await execAsync(`git push "${authedUrl}" HEAD`, { cwd, timeout: 60000 });
return { success: true, message };
}
catch (err) {
const cleaned = (err.message || '').replace(new RegExp(apiToken, 'g'), '***');
return { error: `Git operation failed: ${cleaned}` };
}
}
// ---------------------------------------------------------------------------
// Coolify tools
// ---------------------------------------------------------------------------
async function coolifyFetch(path, ctx, method = 'GET', body) {
const res = await fetch(`${ctx.coolify.apiUrl}/api/v1${path}`, {
method,
headers: {
'Authorization': `Bearer ${ctx.coolify.apiToken}`,
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: body ? JSON.stringify(body) : undefined
});
if (!res.ok)
return { error: `Coolify API error: ${res.status} ${res.statusText}` };
return res.json();
}
async function coolifyListProjects(ctx) {
return coolifyFetch('/projects', ctx);
}
async function coolifyListApplications(projectUuid, ctx) {
const all = await coolifyFetch('/applications', ctx);
if (!Array.isArray(all))
return all;
return all.filter((a) => a.project_uuid === projectUuid);
}
async function coolifyDeploy(appUuid, ctx) {
return coolifyFetch(`/applications/${appUuid}/deploy`, ctx, 'POST');
}
async function coolifyGetLogs(appUuid, ctx) {
return coolifyFetch(`/applications/${appUuid}/logs?limit=50`, ctx);
}
// ---------------------------------------------------------------------------
// Gitea tools
// ---------------------------------------------------------------------------
async function giteaFetch(path, ctx, method = 'GET', body) {
const res = await fetch(`${ctx.gitea.apiUrl}/api/v1${path}`, {
method,
headers: {
'Authorization': `token ${ctx.gitea.apiToken}`,
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: body ? JSON.stringify(body) : undefined
});
if (!res.ok)
return { error: `Gitea API error: ${res.status} ${res.statusText}` };
return res.json();
}
async function giteaCreateIssue(repo, title, body, labels, ctx) {
return giteaFetch(`/repos/${repo}/issues`, ctx, 'POST', { title, body, labels });
}
async function giteaListIssues(repo, state, ctx) {
return giteaFetch(`/repos/${repo}/issues?state=${state}&limit=20`, ctx);
}
async function giteaCloseIssue(repo, issueNumber, ctx) {
return giteaFetch(`/repos/${repo}/issues/${issueNumber}`, ctx, 'PATCH', { state: 'closed' });
}
// ---------------------------------------------------------------------------
// Spawn agent (used by Orchestrator to delegate to specialists)
// Calls back to the AgentRunner HTTP API to queue a new job.
// ---------------------------------------------------------------------------
async function spawnAgentTool(agent, task, repo, _ctx) {
const runnerUrl = process.env.AGENT_RUNNER_URL || 'http://localhost:3333';
try {
const res = await fetch(`${runnerUrl}/api/agent/run`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Internal': 'true' },
body: JSON.stringify({ agent, task, repo })
});
const data = await res.json();
return { jobId: data.jobId, agent, status: 'dispatched' };
}
catch (err) {
return { error: `Failed to spawn agent: ${err instanceof Error ? err.message : String(err)}` };
}
}