wire up /agent/execute and /agent/stop endpoints

- Add runSessionAgent: streaming variant of runAgent that PATCHes VIBN DB
  after every LLM turn and tool call so frontend can poll live output
- Track changed files from write_file / replace_in_file tool calls
- Add /agent/execute: receives sessionId + giteaRepo + task, clones repo,
  scopes workspace to appPath, runs Coder agent async (returns 202 immediately)
- Add /agent/stop: sets stopped flag; agent checks between turns and exits cleanly
- Agent does NOT commit on completion — leaves changes for user review/approval

Made-with: Cursor
This commit is contained in:
2026-03-06 18:01:30 -08:00
parent 335c7a7e97
commit 5aeddace91
13 changed files with 850 additions and 5 deletions

244
src/agent-session-runner.ts Normal file
View File

@@ -0,0 +1,244 @@
/**
* 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 { createLLM, toOAITools, LLMMessage } from './llm';
import { AgentConfig } from './agents';
import { executeTool, ToolContext } from './tools';
import { resolvePrompt } from './prompts/loader';
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"
isStopped: () => boolean;
}
// ── 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 };
}
// ── 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.
When you are done, do NOT commit directly — leave the changes uncommitted so the user can review and approve them.
`;
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 });
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)})`;
}
}