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

149
src/agent-runner.ts Normal file
View File

@@ -0,0 +1,149 @@
import { GoogleGenAI, Content, Tool, FunctionDeclaration } from '@google/genai';
import { AgentConfig } from './agents';
import { executeTool, ToolContext } from './tools';
import { Job, updateJob } from './job-store';
const MAX_TURNS = 40; // safety cap — prevents infinite loops
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 async function runAgent(
job: Job,
config: AgentConfig,
task: string,
ctx: ToolContext
): Promise<RunResult> {
const apiKey = process.env.GOOGLE_API_KEY;
if (!apiKey) {
throw new Error('GOOGLE_API_KEY environment variable is not set');
}
const genai = new GoogleGenAI({ apiKey });
// Build Gemini function declarations from our tool definitions
const functionDeclarations: FunctionDeclaration[] = config.tools.map(tool => ({
name: tool.name,
description: tool.description,
parameters: tool.parameters as any
}));
const tools: Tool[] = functionDeclarations.length > 0
? [{ functionDeclarations }]
: [];
const model = genai.models;
// Build conversation history
const history: Content[] = [];
// Initial user message
let currentMessage: Content = {
role: 'user',
parts: [{ text: task }]
};
let toolCallCount = 0;
let turn = 0;
let finalText = '';
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: Content = {
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: any[] = [];
for (const part of functionCalls) {
const call = part.functionCall!;
const callName = call.name ?? 'unknown';
const callArgs = (call.args ?? {}) as Record<string, unknown>;
toolCallCount++;
updateJob(job.id, {
progress: `Turn ${turn}: calling ${callName}...`,
toolCalls: [...(job.toolCalls || []), {
turn,
tool: callName,
args: callArgs,
timestamp: new Date().toISOString()
}]
});
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 }
}
});
}
// 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 };
}

132
src/agents.ts Normal file
View File

@@ -0,0 +1,132 @@
import { ToolDefinition, ALL_TOOLS } from './tools';
// ---------------------------------------------------------------------------
// Agent configuration — which tools each agent gets + system prompt
// ---------------------------------------------------------------------------
export interface AgentConfig {
name: string;
description: string;
model: string;
systemPrompt: string;
tools: ToolDefinition[];
}
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: string[]): ToolDefinition[] {
return ALL_TOOLS.filter(t => names.includes(t.name));
}
// ---------------------------------------------------------------------------
// Agent definitions
// ---------------------------------------------------------------------------
export const AGENTS: Record<string, AgentConfig> = {
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])
}
};

69
src/job-store.ts Normal file
View File

@@ -0,0 +1,69 @@
import { v4 as uuidv4 } from 'uuid';
// ---------------------------------------------------------------------------
// Job types
// ---------------------------------------------------------------------------
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;
}
// ---------------------------------------------------------------------------
// In-memory store (swap for Redis/DB if scaling horizontally)
// ---------------------------------------------------------------------------
const store = new Map<string, Job>();
export function createJob(agent: string, task: string, repo?: string): Job {
const job: Job = {
id: uuidv4(),
agent,
task,
repo,
status: 'queued',
progress: 'Job queued',
toolCalls: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
};
store.set(job.id, job);
return job;
}
export function getJob(id: string): Job | undefined {
return store.get(id);
}
export function updateJob(id: string, updates: Partial<Job>): Job | undefined {
const job = store.get(id);
if (!job) return undefined;
const updated = { ...job, ...updates, id, updatedAt: new Date().toISOString() };
store.set(id, updated);
return updated;
}
export function listJobs(limit = 50): Job[] {
const all = Array.from(store.values());
return all
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
.slice(0, limit);
}

198
src/server.ts Normal file
View File

@@ -0,0 +1,198 @@
import express, { Request, Response, NextFunction } from 'express';
import cors from 'cors';
import { createJob, getJob, listJobs, updateJob } from './job-store';
import { runAgent } from './agent-runner';
import { AGENTS } from './agents';
import { ToolContext } from './tools';
const app = express();
app.use(cors());
app.use(express.json());
const PORT = process.env.PORT || 3333;
// ---------------------------------------------------------------------------
// Build ToolContext from environment variables
// ---------------------------------------------------------------------------
function buildContext(repo?: string): ToolContext {
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: Request, res: Response) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// List available agents
app.get('/api/agents', (_req: Request, res: Response) => {
const agents = Object.values(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: Request, res: Response) => {
const { agent: agentName, task, repo } = req.body as { agent?: string; task?: string; repo?: string };
if (!agentName || !task) {
res.status(400).json({ error: '"agent" and "task" are required' });
return;
}
const agentConfig = AGENTS[agentName];
if (!agentConfig) {
const available = Object.keys(AGENTS).join(', ');
res.status(400).json({ error: `Unknown agent "${agentName}". Available: ${available}` });
return;
}
const job = createJob(agentName, task, repo);
res.status(202).json({ jobId: job.id, status: job.status });
// Run agent asynchronously
const ctx = buildContext(repo);
runAgent(job, agentConfig, task, ctx)
.then(result => {
updateJob(job.id, {
status: 'completed',
result: result.finalText,
progress: `Done — ${result.turns} turns, ${result.toolCallCount} tool calls`
});
})
.catch(err => {
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: Request, res: Response) => {
const job = 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: Request, res: Response) => {
const limit = parseInt((req.query.limit as string) || '20', 10);
res.json(listJobs(limit));
});
// Gitea webhook endpoint — triggers agent from a push/issue event
app.post('/webhook/gitea', (req: Request, res: Response) => {
const event = req.headers['x-gitea-event'] as string;
const body = req.body as any;
// Verify secret if configured
const webhookSecret = process.env.WEBHOOK_SECRET;
if (webhookSecret) {
const sig = req.headers['x-gitea-signature'] as string;
if (!sig || sig !== webhookSecret) {
res.status(401).json({ error: 'Invalid webhook signature' });
return;
}
}
let task: string | null = null;
let agentName = 'Coder';
let repo: string | undefined;
if (event === 'issues' && body.action === 'opened') {
const issue = body.issue;
repo = `${body.repository?.owner?.login}/${body.repository?.name}`;
const labels: string[] = (issue.labels || []).map((l: any) => l.name as string);
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[agentName];
const job = createJob(agentName, task, repo);
res.status(202).json({ jobId: job.id, agent: agentName, event });
const ctx = buildContext(repo);
runAgent(job, agentConfig, task, ctx)
.then(result => {
updateJob(job.id, {
status: 'completed',
result: result.finalText,
progress: `Done — ${result.turns} turns, ${result.toolCallCount} tool calls`
});
})
.catch(err => {
updateJob(job.id, {
status: 'failed',
error: err instanceof Error ? err.message : String(err),
progress: 'Agent failed'
});
});
});
// ---------------------------------------------------------------------------
// Error handler
// ---------------------------------------------------------------------------
app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
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).join(', ')}`);
if (!process.env.GOOGLE_API_KEY) {
console.warn('WARNING: GOOGLE_API_KEY is not set — agents will fail');
}
});

473
src/tools.ts Normal file
View File

@@ -0,0 +1,473 @@
import * as fs from 'fs';
import * as path from 'path';
import * as cp from 'child_process';
import * as util from 'util';
import { Minimatch } from 'minimatch';
const execAsync = util.promisify(cp.exec);
// ---------------------------------------------------------------------------
// Context passed to every tool call — workspace root + credentials
// ---------------------------------------------------------------------------
export interface ToolContext {
workspaceRoot: string;
gitea: {
apiUrl: string;
apiToken: string;
username: string;
};
coolify: {
apiUrl: string;
apiToken: string;
};
}
// ---------------------------------------------------------------------------
// Tool definitions (schema for Gemini function calling)
// ---------------------------------------------------------------------------
export interface ToolDefinition {
name: string;
description: string;
parameters: Record<string, unknown>;
}
export const ALL_TOOLS: ToolDefinition[] = [
{
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
// ---------------------------------------------------------------------------
export async function executeTool(
name: string,
args: Record<string, unknown>,
ctx: ToolContext
): Promise<unknown> {
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 as string[] | undefined, ctx);
case 'execute_command': return executeCommand(String(args.command), args.working_directory as string | undefined, 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 as string[] | undefined, 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 '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: string, rel: string): string {
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: string, ctx: ToolContext): Promise<string> {
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: string, content: string, ctx: ToolContext): Promise<unknown> {
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: string, oldContent: string, newContent: string, ctx: ToolContext): Promise<unknown> {
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: string, ctx: ToolContext): Promise<unknown> {
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: string, ctx: ToolContext): Promise<unknown> {
const matcher = new Minimatch(pattern, { dot: false });
const results: string[] = [];
function walk(dir: string): void {
if (results.length >= 200) return;
let entries: fs.Dirent[];
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: string, extensions: string[] | undefined, ctx: ToolContext): Promise<unknown> {
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: any) {
if (err.code === 1) return []; // ripgrep exit 1 = no matches
return { error: `Search failed: ${err.message}` };
}
}
async function executeCommand(command: string, workingDir: string | undefined, ctx: ToolContext): Promise<unknown> {
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: 120_000, maxBuffer: 1024 * 1024 });
return { exitCode: 0, stdout: stdout.trim(), stderr: stderr.trim() };
} catch (err: any) {
return { exitCode: err.code, stdout: (err.stdout || '').trim(), stderr: (err.stderr || '').trim(), error: err.message };
}
}
async function gitCommitAndPush(message: string, ctx: ToolContext): Promise<unknown> {
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: 60_000 });
return { success: true, message };
} catch (err: any) {
const cleaned = (err.message || '').replace(new RegExp(apiToken, 'g'), '***');
return { error: `Git operation failed: ${cleaned}` };
}
}
// ---------------------------------------------------------------------------
// Coolify tools
// ---------------------------------------------------------------------------
async function coolifyFetch(path: string, ctx: ToolContext, method = 'GET', body?: unknown): Promise<unknown> {
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: ToolContext): Promise<unknown> {
return coolifyFetch('/projects', ctx);
}
async function coolifyListApplications(projectUuid: string, ctx: ToolContext): Promise<unknown> {
const all = await coolifyFetch('/applications', ctx) as any[];
if (!Array.isArray(all)) return all;
return all.filter((a: any) => a.project_uuid === projectUuid);
}
async function coolifyDeploy(appUuid: string, ctx: ToolContext): Promise<unknown> {
return coolifyFetch(`/applications/${appUuid}/deploy`, ctx, 'POST');
}
async function coolifyGetLogs(appUuid: string, ctx: ToolContext): Promise<unknown> {
return coolifyFetch(`/applications/${appUuid}/logs?limit=50`, ctx);
}
// ---------------------------------------------------------------------------
// Gitea tools
// ---------------------------------------------------------------------------
async function giteaFetch(path: string, ctx: ToolContext, method = 'GET', body?: unknown): Promise<unknown> {
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: string, title: string, body: string, labels: string[] | undefined, ctx: ToolContext): Promise<unknown> {
return giteaFetch(`/repos/${repo}/issues`, ctx, 'POST', { title, body, labels });
}
async function giteaListIssues(repo: string, state: string, ctx: ToolContext): Promise<unknown> {
return giteaFetch(`/repos/${repo}/issues?state=${state}&limit=20`, ctx);
}
async function giteaCloseIssue(repo: string, issueNumber: number, ctx: ToolContext): Promise<unknown> {
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: string, task: string, repo: string, _ctx: ToolContext): Promise<unknown> {
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() as any;
return { jobId: data.jobId, agent, status: 'dispatched' };
} catch (err) {
return { error: `Failed to spawn agent: ${err instanceof Error ? err.message : String(err)}` };
}
}