This repository has been archived on 2026-06-07. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
master-ai/vibn-agent-runner/dist/agent-session-runner.js

500 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use strict";
/**
* agent-session-runner.ts
*
* Upgraded Cloud Agent Executor for VibnCode.
* Implements 4-level Smart Concurrency (parallel reads/lookups) and the
* Ralph Loop (autonomous self-correction) entirely inside your secure Cloud VM.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.runSessionAgent = runSessionAgent;
const child_process_1 = require("child_process");
const vibn_chat_model_1 = require("./llm/vibn-chat-model");
const tools_1 = require("./tools");
const loader_1 = require("./prompts/loader");
const MAX_TURNS = 45;
function runBuildVerification(repoRoot, appPath) {
const fs = require("fs");
const path = require("path");
const { execSync } = require("child_process");
const absoluteAppPath = path.join(repoRoot, appPath);
const pkgJsonPath = path.join(absoluteAppPath, "package.json");
if (!fs.existsSync(pkgJsonPath)) {
return { success: true }; // No package.json, skip build check
}
try {
const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, "utf8"));
// Only verify if there is an explicit build script
if (!pkg.scripts || !pkg.scripts.build) {
return { success: true };
}
console.log(`[Ralph Loop] Running automatic build verification: npm run build inside ${absoluteAppPath}...`);
// Run npm run build with a 45s timeout to prevent hanging
execSync("npm run build", {
cwd: absoluteAppPath,
stdio: "pipe",
timeout: 45000,
});
return { success: true };
}
catch (err) {
const stderr = err.stderr
? err.stderr.toString()
: err.message || String(err);
console.warn(`[Ralph Loop] Build verification failed:`, stderr);
return {
success: false,
error: stderr.slice(-3000), // Cap the log length to avoid flooding the prompt context
};
}
}
// ── 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) {
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",
"fs_write",
"fs_edit",
]);
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" || toolName === "fs_write" ? "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 systemPrompt = (0, loader_1.resolvePrompt)(config.promptId);
const emit = async (line) => {
console.log(`[session ${opts.sessionId}] ${line.type}: ${line.text}`);
await patchSession(opts, { outputLine: line });
};
await emit({
ts: now(),
type: "info",
text: `Agent starting 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}
\n\n## 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 toolCallsSinceText = 0;
let roundsSinceText = 0;
const toolFingerprints = [];
let loopBreakReason = null;
let ralphIteration = 0;
function fingerprintToolCall(tc) {
if (tc.name === "shell_exec") {
const cmd = String(tc.args?.command ?? "").trim();
const verb = cmd
.split("&&")
.map((s) => s.trim())
.find((s) => !s.startsWith("cd "))
?.split(/\s+/)[0] ?? "shell";
return `shell_exec:${verb}`;
}
if (tc.name === "fs_write" ||
tc.name === "fs_edit" ||
tc.name === "fs_read") {
return `${tc.name}:${tc.args?.path}`;
}
return `${tc.name}:${Object.values(tc.args ?? {})[0]}`;
}
while (turn < MAX_TURNS) {
if (opts.isStopped()) {
await emit({ ts: now(), type: "info", text: "Stopped by user." });
await patchSession(opts, { status: "stopped" });
return;
}
turn++;
const isSilent = roundsSinceText >= 15 || toolCallsSinceText >= 20;
const extraSystem = isSilent
? "\n\n[STATUS NUDGE] You have run " +
`${toolCallsSinceText} tool call(s) over ${roundsSinceText} round(s) ` +
"without sending the user any text. Before any more tool calls, " +
"send ONE short sentence describing what you are currently working " +
"on and why."
: "";
let resp;
try {
resp = await (0, vibn_chat_model_1.callVibnChat)({
systemPrompt: scopedPrompt + extraSystem,
messages: history,
tools: config.tools,
temperature: 0.2,
});
}
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;
}
if (resp.error) {
await emit({
ts: now(),
type: "error",
text: `LLM error: ${resp.error}`,
});
await patchSession(opts, { status: "failed", error: resp.error });
return;
}
if (resp.text) {
await emit({ ts: now(), type: "info", text: resp.text });
roundsSinceText = 0;
toolCallsSinceText = 0;
}
else if (resp.toolCalls.length) {
roundsSinceText++;
toolCallsSinceText += resp.toolCalls.length;
}
// ── Self-Correcting Ralph Loop Autonomy ──
if (!resp.toolCalls.length) {
const text = resp.text || "";
const incompleteSignals = [
"I need to",
"Let me",
"Next, I should",
"I should also",
"Additionally",
"I will now",
"I need first to",
];
const needsMoreWork = incompleteSignals.some((signal) => text.includes(signal));
if (needsMoreWork && ralphIteration < 3) {
ralphIteration++;
await emit({
ts: now(),
type: "info",
text: `🔄 [Ralph Loop] Self-reflection triggered (iteration ${ralphIteration}/3). Resuming execution...`,
});
history.push({
role: "user",
content: "Please continue implementing the outstanding next steps to complete the task.",
});
continue;
}
// ── Cloud Build Verification (Ralph Loop integration) ──
if (opts.repoRoot && ralphIteration < 3) {
await emit({
ts: now(),
type: "info",
text: "🔍 [Ralph Loop] Initiating automatic build verification...",
});
const verification = runBuildVerification(opts.repoRoot, opts.appPath);
if (!verification.success) {
ralphIteration++;
await emit({
ts: now(),
type: "error",
text: `❌ [Ralph Loop] Build verification failed (iteration ${ralphIteration}/3). Feeding compilation errors back to the model...`,
});
history.push({
role: "user",
content: `Your previous edits completed, but the project's build check failed with compilation errors.
=========================================
🚨 SURGICAL HEALING PROTOCOL ACTIVE 🚨
=========================================
The project's compilation/build has failed. You are currently in an autonomous, auto-correcting healing loop and must fix this compilation error immediately.
To prevent cognitive loop spirals and command limits, you MUST follow this strict, non-negotiable troubleshooting protocol:
1. 🚫 STRICTLY BLOCK EXPLORATION: DO NOT execute general directory exploration or orientation commands such as 'ls', 'find', 'pwd', 'grep', 'git status', 'git diff', or other search commands. You do not need to look around.
2. 🎯 SURGICAL TARGETING: Scan the compiler error logs below to locate the EXACT filename, line number, and column where the compilation failed.
3. 🛠️ IMMEDIATE CORRECTION: Read that file immediately using your specific file-reading tool (using precise start/end lines if it is large) and apply a targeted, surgical edit to correct the exact syntax or type error. Do not write a placeholder or partial fix.
Here are the precise compilation errors from the compiler:
\`\`\`text
${verification.error}
\`\`\`
Implement the exact fix directly in the code now.`,
});
continue;
}
else {
await emit({
ts: now(),
type: "info",
text: "🟢 [Ralph Loop] Build verification passed successfully! 0 errors.",
});
}
}
// If fully complete, trigger auto-commit and finish
if (opts.autoApprove) {
await autoCommitAndDeploy(opts, task, emit);
}
else {
await patchSession(opts, { status: "completed" });
}
return;
}
for (const tc of resp.toolCalls) {
toolFingerprints.push(fingerprintToolCall(tc));
}
const window = toolFingerprints.slice(-10);
const counts = new Map();
for (const fp of window)
counts.set(fp, (counts.get(fp) ?? 0) + 1);
let maxRepeats = 0;
let repeatedCmd = "";
for (const [fp, n] of counts.entries()) {
if (n > maxRepeats) {
maxRepeats = n;
repeatedCmd = fp.split("|")[0];
}
}
if (maxRepeats >= 6) {
loopBreakReason = `Repeated ${repeatedCmd} ${maxRepeats}× in last 10 calls`;
break;
}
history.push({
role: "assistant",
content: resp.text,
toolCalls: resp.toolCalls,
});
// ── 4-Level Smart Concurrency Tool Grouping ──
const parallelReads = resp.toolCalls.filter((tc) => [
"fs_read",
"fs_tree",
"fs_list",
"fs_glob",
"fs_grep",
"projects_list",
"project_recent_errors",
].includes(tc.name));
const sequentialWrites = resp.toolCalls.filter((tc) => [
"fs_write",
"fs_edit",
"create_file",
"write_file",
"replace_in_file",
"apps_create",
"databases_create",
].includes(tc.name));
const otherTools = resp.toolCalls.filter((tc) => !parallelReads.includes(tc) && !sequentialWrites.includes(tc));
// Stage 1: Parallel Reads
if (parallelReads.length > 0) {
await emit({
ts: now(),
type: "step",
text: `Executing ${parallelReads.length} read operations concurrently...`,
});
await Promise.all(parallelReads.map(async (tc) => {
let result;
try {
result = await (0, tools_1.executeTool)(tc.name, tc.args, ctx);
}
catch (err) {
result = {
error: err instanceof Error ? err.message : String(err),
};
}
const resultStr = typeof result === "string"
? result
: JSON.stringify(result, null, 2);
history.push({
role: "tool",
content: resultStr,
toolCallId: tc.id,
toolName: tc.name,
});
}));
}
// Stage 2: Parallelizable Other Tools
if (otherTools.length > 0) {
await Promise.all(otherTools.map(async (tc) => {
await emit({
ts: now(),
type: "step",
text: `Running ${tc.name}...`,
});
let result;
try {
result = await (0, tools_1.executeTool)(tc.name, tc.args, ctx);
}
catch (err) {
result = {
error: err instanceof Error ? err.message : String(err),
};
}
const resultStr = typeof result === "string"
? result
: JSON.stringify(result, null, 2);
history.push({
role: "tool",
content: resultStr,
toolCallId: tc.id,
toolName: tc.name,
});
}));
}
// Stage 3: Sequential User-Safe Writes/Edits
if (sequentialWrites.length > 0) {
for (const tc of sequentialWrites) {
await emit({
ts: now(),
type: "step",
text: `Writing modifications: ${tc.name}...`,
});
let result;
try {
result = await (0, tools_1.executeTool)(tc.name, tc.args, ctx);
const changedFile = extractChangedFile(tc.name, tc.args, ctx.workspaceRoot, opts.appPath);
if (changedFile) {
await patchSession(opts, { changedFile });
}
}
catch (err) {
result = { error: err instanceof Error ? err.message : String(err) };
}
const resultStr = typeof result === "string" ? result : JSON.stringify(result, null, 2);
history.push({
role: "tool",
content: resultStr,
toolCallId: tc.id,
toolName: tc.name,
});
}
}
}
if (loopBreakReason) {
await emit({
ts: now(),
type: "error",
text: `Loop broken: ${loopBreakReason}`,
});
await patchSession(opts, { status: "failed", error: loopBreakReason });
}
else {
await patchSession(opts, { status: "failed", error: "Max turns reached" });
}
}