Files
vibn-agent-runner/src/server.ts
mawkone e8fdbff9f4 Atlas: opening message, isInit flag, strip trigger from history
- Add opening message instruction to atlas prompt
- Handle isInit flag in atlasChat() to not store the greeting trigger
  as a user turn in conversation history
- Update server.ts to pass is_init through to atlasChat()

Made-with: Cursor
2026-03-02 16:55:16 -08:00

366 lines
12 KiB
TypeScript

import express, { Request, Response, NextFunction } from 'express';
import cors from 'cors';
import * as fs from 'fs';
import * as path from 'path';
import * as crypto from 'crypto';
import { execSync } from 'child_process';
import { createJob, getJob, listJobs, updateJob } from './job-store';
import { runAgent } from './agent-runner';
import { AGENTS } from './agents';
import { ToolContext } from './tools';
import { PROTECTED_GITEA_REPOS } from './tools/security';
import { orchestratorChat, listSessions, clearSession } from './orchestrator';
import { atlasChat, listAtlasSessions, clearAtlasSession } from './atlas';
import { LLMMessage } from './llm';
const app = express();
app.use(cors());
const startTime = new Date();
// Raw body capture for webhook HMAC — must come before express.json()
app.use('/webhook/gitea', express.raw({ type: '*/*' }));
app.use(express.json());
const PORT = process.env.PORT || 3333;
// ---------------------------------------------------------------------------
// Build ToolContext from environment variables
// ---------------------------------------------------------------------------
function ensureWorkspace(repo?: string): string {
const base = process.env.WORKSPACE_BASE || '/workspaces';
if (!repo) {
const dir = path.join(base, 'default');
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 || '',
apiToken: process.env.GITEA_API_TOKEN || '',
username: process.env.GITEA_USERNAME || ''
};
if (!fs.existsSync(path.join(dir, '.git'))) {
fs.mkdirSync(dir, { recursive: true });
const authedUrl = `${gitea.apiUrl}/${repo}.git`
.replace('https://', `https://${gitea.username}:${gitea.apiToken}@`);
try {
execSync(`git clone "${authedUrl}" "${dir}"`, { stdio: 'pipe' });
} catch {
// Repo may not exist yet — just init
execSync(`git init`, { cwd: dir, stdio: 'pipe' });
execSync(`git remote add origin "${authedUrl}"`, { cwd: dir, stdio: 'pipe' });
}
}
return dir;
}
function buildContext(repo?: string): ToolContext {
const workspaceRoot = ensureWorkspace(repo);
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 || ''
},
memoryUpdates: []
};
}
// ---------------------------------------------------------------------------
// 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);
});
// Get server status and job statistics
app.get('/api/status', (_req: Request, res: Response) => {
const allJobs = listJobs(Infinity);
const total_jobs = allJobs.length;
const by_status: { [key: string]: number } = {
queued: 0,
running: 0,
completed: 0,
failed: 0,
};
for (const job of allJobs) {
by_status[job.status] = (by_status[job.status] || 0) + 1;
}
const uptime_seconds = Math.floor((new Date().getTime() - startTime.getTime()) / 1000);
const agents = Object.values(AGENTS).map(a => a.name);
res.json({
total_jobs,
by_status,
uptime_seconds,
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);
});
// ---------------------------------------------------------------------------
// Orchestrator — persistent chat with full project context
// ---------------------------------------------------------------------------
app.post('/orchestrator/chat', async (req: Request, res: Response) => {
const {
message,
session_id,
history,
knowledge_context
} = req.body as {
message?: string;
session_id?: string;
history?: LLMMessage[];
knowledge_context?: 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, {
preloadedHistory: history,
knowledgeContext: knowledge_context
});
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 });
});
// ---------------------------------------------------------------------------
// Atlas — PRD discovery agent
// ---------------------------------------------------------------------------
app.post('/atlas/chat', async (req: Request, res: Response) => {
const {
message,
session_id,
history,
is_init,
} = req.body as {
message?: string;
session_id?: string;
history?: LLMMessage[];
is_init?: boolean;
};
if (!message) { res.status(400).json({ error: '"message" is required' }); return; }
const sessionId = session_id || `atlas_${Date.now()}`;
const ctx = buildContext();
try {
const result = await atlasChat(sessionId, message, ctx, {
preloadedHistory: history,
isInit: is_init,
});
res.json(result);
} catch (err) {
res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
}
});
app.get('/atlas/sessions', (_req: Request, res: Response) => {
res.json(listAtlasSessions());
});
app.delete('/atlas/sessions/:id', (req: Request, res: Response) => {
clearAtlasSession(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);
res.json(listJobs(limit));
});
// Gitea webhook endpoint — triggers agent from an issue event
app.post('/webhook/gitea', (req: Request, res: Response) => {
const event = req.headers['x-gitea-event'] as string;
const rawBody = req.body as Buffer;
// Verify HMAC-SHA256 signature
const webhookSecret = process.env.WEBHOOK_SECRET;
if (webhookSecret) {
const sig = req.headers['x-gitea-signature'] as string;
const expected = crypto
.createHmac('sha256', webhookSecret)
.update(rawBody)
.digest('hex');
if (!sig || !crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
res.status(401).json({ error: 'Invalid webhook signature' });
return;
}
}
const body = JSON.parse(rawBody.toString('utf8'));
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 if (labels.includes('agent:coder')) {
agentName = 'Coder';
} else {
// No agent label — ignore
res.json({ ignored: true, reason: 'no agent label on issue' });
return;
}
task = `You have been assigned to resolve a Gitea issue in the repo ${repo}.\n\nIssue #${issue.number}: ${issue.title}\n\nDescription:\n${issue.body || '(no description)'}\n\nWhen done, close the issue by calling gitea_close_issue.`;
} else if (event === 'push') {
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');
}
});