330 lines
13 KiB
JavaScript
330 lines
13 KiB
JavaScript
"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 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 {
|
|
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)}`;
|
|
const msgFile = require("path").join(opts.repoRoot || process.cwd(), ".git", "COMMIT_EDITMSG");
|
|
require("fs").writeFileSync(msgFile, commitMsg, "utf8");
|
|
(0, child_process_1.execSync)("git commit -F .git/COMMIT_EDITMSG", gitOpts);
|
|
try {
|
|
require("fs").unlinkSync(msgFile);
|
|
}
|
|
catch { }
|
|
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 {
|
|
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)})`;
|
|
}
|
|
}
|