Files
vibn-frontend/vibn-agent-runner/src/agent-session-runner.ts

423 lines
13 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 { ingestSessionEvents } from "./vibn-events-ingest";
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;
}
// ── 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 {
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)}`;
const msgFile = require("path").join(
opts.repoRoot || process.cwd(),
".git",
"COMMIT_EDITMSG",
);
require("fs").writeFileSync(msgFile, commitMsg, "utf8");
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}@`,
);
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 Promise.all([
patchSession(opts, { outputLine: line }),
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 = 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 {
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)})`;
}
}