- theia-exec.ts: primary path is now HTTP sync (syncRepoToTheia) via sync-server.js running inside Theia on port 3001 — no docker socket needed - syncRepoToTheia(giteaRepo): POST /sync → Theia git-pulls latest committed code - isTheiaSyncAvailable(): health check before attempting sync - docker exec path preserved for future use when socket is mounted - agent-session-runner: use syncRepoToTheia after auto-commit - server.ts: log both docker exec + HTTP sync status at startup Made-with: Cursor
357 lines
15 KiB
TypeScript
357 lines
15 KiB
TypeScript
/**
|
|
* agent-session-runner.ts
|
|
*
|
|
* Streaming variant of runAgent wired to a VIBN agent_sessions row.
|
|
* After every LLM turn + tool call, it PATCHes the session in the VIBN DB
|
|
* so the frontend can poll (and later WebSocket) the live output.
|
|
*
|
|
* Key differences from runAgent:
|
|
* - Accepts an `emit` callback instead of updating job-store
|
|
* - Accepts an `isStopped` check so the frontend can cancel mid-run
|
|
* - Tracks which files were written/modified for the changed_files panel
|
|
* - Calls vibn-frontend's PATCH /api/projects/[id]/agent/sessions/[sid]
|
|
*/
|
|
|
|
import { execSync } from 'child_process';
|
|
import { createLLM, toOAITools, LLMMessage } from './llm';
|
|
import { AgentConfig } from './agents';
|
|
import { executeTool, ToolContext } from './tools';
|
|
import { resolvePrompt } from './prompts/loader';
|
|
import { isTheiaAvailable, theiaExec, syncRepoToTheia, isTheiaSyncAvailable } from './theia-exec';
|
|
|
|
const MAX_TURNS = 60;
|
|
|
|
export interface OutputLine {
|
|
ts: string;
|
|
type: 'step' | 'stdout' | 'stderr' | 'info' | 'error' | 'done';
|
|
text: string;
|
|
}
|
|
|
|
export interface SessionRunOptions {
|
|
sessionId: string;
|
|
projectId: string;
|
|
vibnApiUrl: string; // e.g. https://vibnai.com
|
|
appPath: string; // relative path within repo, e.g. "apps/admin"
|
|
repoRoot?: string; // absolute path to the git repo root (for auto-commit)
|
|
isStopped: () => boolean;
|
|
// Auto-approve: commit + push + deploy without user confirmation
|
|
autoApprove?: boolean;
|
|
giteaRepo?: string; // e.g. "mark/sportsy"
|
|
coolifyAppUuid?: string;
|
|
coolifyApiUrl?: string;
|
|
coolifyApiToken?: string;
|
|
// Theia integration
|
|
theiaWorkspaceSubdir?: string; // e.g. "mark_sportsy" — subdir inside /home/node/workspace
|
|
}
|
|
|
|
// ── VIBN DB bridge ────────────────────────────────────────────────────────────
|
|
|
|
async function patchSession(
|
|
opts: SessionRunOptions,
|
|
payload: {
|
|
status?: string;
|
|
outputLine?: OutputLine;
|
|
changedFile?: { path: string; status: string };
|
|
error?: string;
|
|
}
|
|
): Promise<void> {
|
|
const url = `${opts.vibnApiUrl}/api/projects/${opts.projectId}/agent/sessions/${opts.sessionId}`;
|
|
try {
|
|
await fetch(url, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json', 'x-agent-runner-secret': process.env.AGENT_RUNNER_SECRET ?? '' },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
} catch (err) {
|
|
// Log but don't crash — output will be lost for this line but loop continues
|
|
console.warn('[session-runner] PATCH failed:', err instanceof Error ? err.message : err);
|
|
}
|
|
}
|
|
|
|
function now(): string {
|
|
return new Date().toISOString();
|
|
}
|
|
|
|
// ── File change tracking ──────────────────────────────────────────────────────
|
|
|
|
const FILE_WRITE_TOOLS = new Set(['write_file', 'replace_in_file', 'create_file']);
|
|
|
|
function extractChangedFile(
|
|
toolName: string,
|
|
args: Record<string, unknown>,
|
|
workspaceRoot: string,
|
|
appPath: string
|
|
): { path: string; status: string } | null {
|
|
if (!FILE_WRITE_TOOLS.has(toolName)) return null;
|
|
const rawPath = String(args.path ?? args.file_path ?? '');
|
|
if (!rawPath) return null;
|
|
|
|
// Make path relative to appPath for display
|
|
const fullPrefix = `${workspaceRoot}/${appPath}/`;
|
|
const appPrefix = `${appPath}/`;
|
|
let displayPath = rawPath
|
|
.replace(fullPrefix, '')
|
|
.replace(appPrefix, '');
|
|
|
|
const fileStatus = toolName === 'write_file' ? 'added' : 'modified';
|
|
return { path: displayPath, status: fileStatus };
|
|
}
|
|
|
|
// ── Auto-commit helper ────────────────────────────────────────────────────────
|
|
|
|
async function autoCommitAndDeploy(
|
|
opts: SessionRunOptions,
|
|
task: string,
|
|
emit: (line: OutputLine) => Promise<void>
|
|
): Promise<void> {
|
|
const repoRoot = opts.repoRoot;
|
|
if (!repoRoot || !opts.giteaRepo) {
|
|
await emit({ ts: now(), type: 'info', text: 'Auto-approve skipped — no repo root available.' });
|
|
return;
|
|
}
|
|
|
|
const gitOpts = { cwd: repoRoot, stdio: 'pipe' as const };
|
|
const giteaApiUrl = process.env.GITEA_API_URL || '';
|
|
const giteaUsername = process.env.GITEA_USERNAME || 'agent';
|
|
const giteaToken = process.env.GITEA_API_TOKEN || '';
|
|
|
|
try {
|
|
// Sync files into Theia via the sync-server so "Open in Theia" shows latest code
|
|
if (opts.giteaRepo && await isTheiaSyncAvailable()) {
|
|
await emit({ ts: now(), type: 'info', text: `Syncing to Theia…` });
|
|
const syncResult = await syncRepoToTheia(opts.giteaRepo);
|
|
if (syncResult.ok) {
|
|
await emit({ ts: now(), type: 'info', text: `✓ Theia synced (${syncResult.action}) — open theia.vibnai.com to inspect.` });
|
|
} else {
|
|
console.warn('[session-runner] Theia sync failed:', syncResult.error);
|
|
}
|
|
}
|
|
|
|
try {
|
|
execSync('git config user.email "agent@vibnai.com"', gitOpts);
|
|
execSync('git config user.name "VIBN Agent"', gitOpts);
|
|
} catch { /* already set */ }
|
|
|
|
execSync('git add -A', gitOpts);
|
|
|
|
const status = execSync('git status --porcelain', gitOpts).toString().trim();
|
|
if (!status) {
|
|
await emit({ ts: now(), type: 'info', text: '✓ No file changes to commit.' });
|
|
await patchSession(opts, { status: 'approved' });
|
|
return;
|
|
}
|
|
|
|
const commitMsg = `agent: ${task.slice(0, 72)}`;
|
|
execSync(`git commit -m ${JSON.stringify(commitMsg)}`, gitOpts);
|
|
await emit({ ts: now(), type: 'info', text: `✓ Committed: "${commitMsg}"` });
|
|
|
|
const authedUrl = `${giteaApiUrl}/${opts.giteaRepo}.git`
|
|
.replace('https://', `https://${giteaUsername}:${giteaToken}@`);
|
|
execSync(`git push "${authedUrl}" HEAD:main`, gitOpts);
|
|
await emit({ ts: now(), type: 'info', text: '✓ Pushed to Gitea.' });
|
|
|
|
// Optional Coolify deploy
|
|
let deployed = false;
|
|
if (opts.coolifyApiUrl && opts.coolifyApiToken && opts.coolifyAppUuid) {
|
|
try {
|
|
const deployRes = await fetch(
|
|
`${opts.coolifyApiUrl}/api/v1/applications/${opts.coolifyAppUuid}/start`,
|
|
{ method: 'POST', headers: { Authorization: `Bearer ${opts.coolifyApiToken}` } }
|
|
);
|
|
deployed = deployRes.ok;
|
|
if (deployed) await emit({ ts: now(), type: 'info', text: '✓ Deployment triggered.' });
|
|
} catch { /* best-effort */ }
|
|
}
|
|
|
|
await patchSession(opts, {
|
|
status: 'approved',
|
|
outputLine: {
|
|
ts: now(), type: 'done',
|
|
text: `✓ Auto-committed & ${deployed ? 'deployed' : 'pushed'}. No approval needed.`,
|
|
},
|
|
});
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
await emit({ ts: now(), type: 'error', text: `Auto-commit failed: ${msg}` });
|
|
// Fall back to done so user can manually approve
|
|
await patchSession(opts, { status: 'done' });
|
|
}
|
|
}
|
|
|
|
// ── Main streaming execution loop ─────────────────────────────────────────────
|
|
|
|
export async function runSessionAgent(
|
|
config: AgentConfig,
|
|
task: string,
|
|
ctx: ToolContext,
|
|
opts: SessionRunOptions
|
|
): Promise<void> {
|
|
const llm = createLLM(config.model, { temperature: 0.2 });
|
|
const oaiTools = toOAITools(config.tools);
|
|
|
|
const emit = async (line: OutputLine) => {
|
|
console.log(`[session ${opts.sessionId}] ${line.type}: ${line.text}`);
|
|
await patchSession(opts, { outputLine: line });
|
|
};
|
|
|
|
await emit({ ts: now(), type: 'info', text: `Agent starting (${llm.modelId}) — working in ${opts.appPath}` });
|
|
|
|
// Scope the system prompt to the specific app within the monorepo
|
|
const basePrompt = resolvePrompt(config.promptId);
|
|
const scopedPrompt = `${basePrompt}
|
|
|
|
## Active context
|
|
You are working inside the monorepo directory: ${opts.appPath}
|
|
All file paths you use should be relative to this directory unless otherwise specified.
|
|
When running commands, always cd into ${opts.appPath} first unless already there.
|
|
Do NOT run git commit or git push — the platform handles committing after you finish.
|
|
`;
|
|
|
|
const history: LLMMessage[] = [
|
|
{ role: 'user', content: task }
|
|
];
|
|
|
|
let turn = 0;
|
|
let finalText = '';
|
|
const trackedFiles = new Map<string, string>(); // path → status
|
|
|
|
while (turn < MAX_TURNS) {
|
|
// Check for stop signal between turns
|
|
if (opts.isStopped()) {
|
|
await emit({ ts: now(), type: 'info', text: 'Stopped by user.' });
|
|
await patchSession(opts, { status: 'stopped' });
|
|
return;
|
|
}
|
|
|
|
turn++;
|
|
await emit({ ts: now(), type: 'info', text: `Turn ${turn} — thinking…` });
|
|
|
|
const messages: LLMMessage[] = [
|
|
{ role: 'system', content: scopedPrompt },
|
|
...history
|
|
];
|
|
|
|
let response: Awaited<ReturnType<typeof llm.chat>>;
|
|
try {
|
|
response = await llm.chat(messages, oaiTools, 8192);
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
await emit({ ts: now(), type: 'error', text: `LLM error: ${msg}` });
|
|
await patchSession(opts, { status: 'failed', error: msg });
|
|
return;
|
|
}
|
|
|
|
const assistantMsg: LLMMessage = {
|
|
role: 'assistant',
|
|
content: response.content,
|
|
tool_calls: response.tool_calls.length > 0 ? response.tool_calls : undefined
|
|
};
|
|
history.push(assistantMsg);
|
|
|
|
// Agent finished — no more tool calls
|
|
if (response.tool_calls.length === 0) {
|
|
finalText = response.content ?? 'Task complete.';
|
|
break;
|
|
}
|
|
|
|
// Execute each tool call
|
|
for (const tc of response.tool_calls) {
|
|
if (opts.isStopped()) break;
|
|
|
|
const fnName = tc.function.name;
|
|
let fnArgs: Record<string, unknown> = {};
|
|
try { fnArgs = JSON.parse(tc.function.arguments || '{}'); } catch { /* bad JSON */ }
|
|
|
|
// Human-readable step label
|
|
const stepLabel = buildStepLabel(fnName, fnArgs);
|
|
await emit({ ts: now(), type: 'step', text: stepLabel });
|
|
|
|
let result: unknown;
|
|
try {
|
|
// Route execute_command through Theia when available so npm/node
|
|
// commands run inside Theia's persistent dev environment
|
|
if (fnName === 'execute_command' && isTheiaAvailable()) {
|
|
const command = String(fnArgs.command ?? '');
|
|
const subCwd = fnArgs.working_directory
|
|
? `${opts.theiaWorkspaceSubdir ?? ''}/${fnArgs.working_directory}`.replace(/\/+/g, '/')
|
|
: opts.theiaWorkspaceSubdir ?? undefined;
|
|
result = await theiaExec(command, subCwd ? `${process.env.THEIA_WORKSPACE ?? '/home/node/workspace'}/${subCwd}` : undefined);
|
|
if ((result as any)?.error && (result as any)?.exitCode !== 0) {
|
|
// Fallback to local execution if Theia exec fails
|
|
console.warn('[session-runner] Theia exec failed, falling back to local:', (result as any).error);
|
|
result = await executeTool(fnName, fnArgs, ctx);
|
|
}
|
|
} else {
|
|
result = await executeTool(fnName, fnArgs, ctx);
|
|
}
|
|
} catch (err) {
|
|
result = { error: err instanceof Error ? err.message : String(err) };
|
|
}
|
|
|
|
// Stream stdout/stderr if present
|
|
if (result && typeof result === 'object') {
|
|
const r = result as Record<string, unknown>;
|
|
if (r.stdout && String(r.stdout).trim()) {
|
|
for (const line of String(r.stdout).split('\n').filter(Boolean).slice(0, 40)) {
|
|
await emit({ ts: now(), type: 'stdout', text: line });
|
|
}
|
|
}
|
|
if (r.stderr && String(r.stderr).trim()) {
|
|
for (const line of String(r.stderr).split('\n').filter(Boolean).slice(0, 20)) {
|
|
await emit({ ts: now(), type: 'stderr', text: line });
|
|
}
|
|
}
|
|
if (r.error) {
|
|
await emit({ ts: now(), type: 'error', text: String(r.error) });
|
|
}
|
|
}
|
|
|
|
// Track file changes
|
|
const changed = extractChangedFile(fnName, fnArgs, ctx.workspaceRoot, opts.appPath);
|
|
if (changed && !trackedFiles.has(changed.path)) {
|
|
trackedFiles.set(changed.path, changed.status);
|
|
await patchSession(opts, { changedFile: changed });
|
|
await emit({ ts: now(), type: 'info', text: `${changed.status === 'added' ? '+ Created' : '~ Modified'} ${changed.path}` });
|
|
}
|
|
|
|
history.push({
|
|
role: 'tool',
|
|
tool_call_id: tc.id,
|
|
name: fnName,
|
|
content: typeof result === 'string' ? result : JSON.stringify(result)
|
|
});
|
|
}
|
|
}
|
|
|
|
if (turn >= MAX_TURNS && !finalText) {
|
|
finalText = `Hit the ${MAX_TURNS}-turn limit. Stopping.`;
|
|
}
|
|
|
|
await emit({ ts: now(), type: 'done', text: finalText });
|
|
|
|
if (opts.autoApprove) {
|
|
await autoCommitAndDeploy(opts, task, emit);
|
|
} else {
|
|
await patchSession(opts, {
|
|
status: 'done',
|
|
outputLine: { ts: now(), type: 'done', text: '✓ Complete — review changes and approve to commit.' },
|
|
});
|
|
}
|
|
}
|
|
|
|
// ── Step label helpers ────────────────────────────────────────────────────────
|
|
|
|
function buildStepLabel(tool: string, args: Record<string, unknown>): string {
|
|
switch (tool) {
|
|
case 'read_file': return `Read ${args.path ?? args.file_path}`;
|
|
case 'write_file': return `Write ${args.path ?? args.file_path}`;
|
|
case 'replace_in_file': return `Edit ${args.path ?? args.file_path}`;
|
|
case 'list_directory': return `List ${args.path ?? '.'}`;
|
|
case 'find_files': return `Find files: ${args.pattern}`;
|
|
case 'search_code': return `Search: ${args.query}`;
|
|
case 'execute_command': return `Run: ${String(args.command ?? '').slice(0, 80)}`;
|
|
case 'git_commit_and_push': return `Git commit: "${args.message}"`;
|
|
default: return `${tool}(${JSON.stringify(args).slice(0, 60)})`;
|
|
}
|
|
}
|