"use strict"; /** * 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] */ Object.defineProperty(exports, "__esModule", { value: true }); exports.runSessionAgent = runSessionAgent; const child_process_1 = require("child_process"); const llm_1 = require("./llm"); const tools_1 = require("./tools"); const loader_1 = require("./prompts/loader"); const theia_exec_1 = require("./theia-exec"); const vibn_events_ingest_1 = require("./vibn-events-ingest"); const MAX_TURNS = 60; // ── VIBN DB bridge ──────────────────────────────────────────────────────────── async function patchSession(opts, payload) { 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() { return new Date().toISOString(); } // ── File change tracking ────────────────────────────────────────────────────── const FILE_WRITE_TOOLS = new Set(['write_file', 'replace_in_file', 'create_file']); function extractChangedFile(toolName, args, workspaceRoot, appPath) { 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, task, emit) { 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' }; 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 (0, theia_exec_1.isTheiaSyncAvailable)()) { await emit({ ts: now(), type: 'info', text: `Syncing to Theia…` }); const syncResult = await (0, theia_exec_1.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 { (0, child_process_1.execSync)('git config user.email "agent@vibnai.com"', gitOpts); (0, child_process_1.execSync)('git config user.name "VIBN Agent"', gitOpts); } catch { /* already set */ } (0, child_process_1.execSync)('git add -A', gitOpts); const status = (0, child_process_1.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)}`; (0, child_process_1.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}@`); (0, child_process_1.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 ───────────────────────────────────────────── async function runSessionAgent(config, task, ctx, opts) { const llm = (0, llm_1.createLLM)(config.model, { temperature: 0.2 }); const oaiTools = (0, llm_1.toOAITools)(config.tools); const emit = async (line) => { console.log(`[session ${opts.sessionId}] ${line.type}: ${line.text}`); await Promise.all([ patchSession(opts, { outputLine: line }), (0, vibn_events_ingest_1.ingestSessionEvents)(opts.vibnApiUrl, opts.projectId, opts.sessionId, [ { type: `output.${line.type}`, payload: { text: line.text }, ts: line.ts, }, ]), ]); }; 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 = (0, loader_1.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 = [ { role: 'user', content: task } ]; let turn = 0; let finalText = ''; const trackedFiles = new Map(); // 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 = [ { role: 'system', content: scopedPrompt }, ...history ]; let response; 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 = { 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 = {}; 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; try { // Route execute_command through Theia when available so npm/node // commands run inside Theia's persistent dev environment if (fnName === 'execute_command' && (0, theia_exec_1.isTheiaAvailable)()) { const command = String(fnArgs.command ?? ''); const subCwd = fnArgs.working_directory ? `${opts.theiaWorkspaceSubdir ?? ''}/${fnArgs.working_directory}`.replace(/\/+/g, '/') : opts.theiaWorkspaceSubdir ?? undefined; result = await (0, theia_exec_1.theiaExec)(command, subCwd ? `${process.env.THEIA_WORKSPACE ?? '/home/node/workspace'}/${subCwd}` : undefined); if (result?.error && result?.exitCode !== 0) { // Fallback to local execution if Theia exec fails console.warn('[session-runner] Theia exec failed, falling back to local:', result.error); result = await (0, tools_1.executeTool)(fnName, fnArgs, ctx); } } else { result = await (0, tools_1.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; 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, args) { 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)})`; } }