fix(runner): remove leftover syntax errors
This commit is contained in:
211
vibn-agent-runner/dist/agent-session-runner.js
vendored
211
vibn-agent-runner/dist/agent-session-runner.js
vendored
@@ -15,10 +15,9 @@
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.runSessionAgent = runSessionAgent;
|
||||
const child_process_1 = require("child_process");
|
||||
const llm_1 = require("./llm");
|
||||
const vibn_chat_model_1 = require("./llm/vibn-chat-model");
|
||||
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) {
|
||||
@@ -154,25 +153,15 @@ async function autoCommitAndDeploy(opts, task, emit) {
|
||||
}
|
||||
// ── 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 systemPrompt = (0, loader_1.resolvePrompt)(config.promptId);
|
||||
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 patchSession(opts, { outputLine: line });
|
||||
};
|
||||
await emit({
|
||||
ts: now(),
|
||||
type: "info",
|
||||
text: `Agent starting (${llm.modelId}) — working in ${opts.appPath}`,
|
||||
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);
|
||||
@@ -186,24 +175,44 @@ Do NOT run git commit or git push — the platform handles committing after you
|
||||
`;
|
||||
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
|
||||
let toolCallsSinceText = 0;
|
||||
let roundsSinceText = 0;
|
||||
const toolFingerprints = [];
|
||||
let loopBreakReason = null;
|
||||
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 < 16) {
|
||||
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;
|
||||
const isSilent = roundsSinceText >= 8 || toolCallsSinceText >= 12;
|
||||
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. The user is staring at silent tool pills."
|
||||
: "";
|
||||
let resp;
|
||||
try {
|
||||
response = await llm.chat(messages, oaiTools, 8192);
|
||||
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);
|
||||
@@ -211,119 +220,71 @@ Do NOT run git commit or git push — the platform handles committing after you
|
||||
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.";
|
||||
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;
|
||||
}
|
||||
if (!resp.toolCalls.length) {
|
||||
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;
|
||||
}
|
||||
// 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 });
|
||||
history.push({
|
||||
role: "assistant",
|
||||
content: resp.text,
|
||||
toolCalls: resp.toolCalls
|
||||
});
|
||||
for (const tc of resp.toolCalls) {
|
||||
await emit({ ts: now(), type: "step", text: `Running ${tc.name}...` });
|
||||
let result;
|
||||
try {
|
||||
result = await (0, tools_1.executeTool)(fnName, fnArgs, ctx);
|
||||
result = await (0, tools_1.executeTool)(tc.name, tc.args, 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}`,
|
||||
});
|
||||
}
|
||||
const resultStr = typeof result === "string" ? result : JSON.stringify(result, null, 2);
|
||||
history.push({
|
||||
role: "tool",
|
||||
tool_call_id: tc.id,
|
||||
name: fnName,
|
||||
content: typeof result === "string" ? result : JSON.stringify(result),
|
||||
content: resultStr,
|
||||
toolCallId: tc.id,
|
||||
toolName: tc.name
|
||||
});
|
||||
}
|
||||
}
|
||||
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);
|
||||
if (loopBreakReason) {
|
||||
await emit({ ts: now(), type: "error", text: `Loop broken: ${loopBreakReason}` });
|
||||
await patchSession(opts, { status: "failed", error: loopBreakReason });
|
||||
}
|
||||
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)})`;
|
||||
await patchSession(opts, { status: "failed", error: "Max turns reached" });
|
||||
}
|
||||
}
|
||||
|
||||
11
vibn-agent-runner/dist/agents/coder.js
vendored
11
vibn-agent-runner/dist/agents/coder.js
vendored
@@ -1,16 +1,11 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
const registry_1 = require("./registry");
|
||||
const tools_1 = require("../tools");
|
||||
(0, registry_1.registerAgent)({
|
||||
name: 'Coder',
|
||||
description: 'Senior software engineer — writes, edits, tests, commits, and pushes code',
|
||||
description: 'Autonomous headless execution agent. Uses the frontend MCP tool definitions.',
|
||||
model: 'B',
|
||||
promptId: 'coder',
|
||||
tools: (0, registry_1.pick)([
|
||||
'read_file', 'write_file', 'replace_in_file', 'list_directory', 'find_files', 'search_code',
|
||||
'execute_command',
|
||||
'git_commit_and_push',
|
||||
'gitea_list_issues', 'gitea_close_issue',
|
||||
'get_skill'
|
||||
])
|
||||
tools: tools_1.ALL_TOOLS
|
||||
});
|
||||
|
||||
10
vibn-agent-runner/dist/agents/index.d.ts
vendored
10
vibn-agent-runner/dist/agents/index.d.ts
vendored
@@ -1,13 +1,3 @@
|
||||
import '../prompts/orchestrator';
|
||||
import '../prompts/coder';
|
||||
import '../prompts/pm';
|
||||
import '../prompts/marketing';
|
||||
import '../prompts/atlas';
|
||||
import '../prompts/import-analyzer';
|
||||
import './orchestrator';
|
||||
import './coder';
|
||||
import './pm';
|
||||
import './marketing';
|
||||
import './atlas';
|
||||
import './import-analyzer';
|
||||
export { AgentConfig, AGENTS, getAgent, allAgents, pick } from './registry';
|
||||
|
||||
14
vibn-agent-runner/dist/agents/index.js
vendored
14
vibn-agent-runner/dist/agents/index.js
vendored
@@ -1,20 +1,10 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.pick = exports.allAgents = exports.getAgent = exports.AGENTS = void 0;
|
||||
// Import prompt templates first — side effects register them before agents reference promptIds
|
||||
require("../prompts/orchestrator");
|
||||
// Import prompt templates first
|
||||
require("../prompts/coder");
|
||||
require("../prompts/pm");
|
||||
require("../prompts/marketing");
|
||||
require("../prompts/atlas");
|
||||
require("../prompts/import-analyzer");
|
||||
// Import agent files — side effects register each agent into the registry
|
||||
require("./orchestrator");
|
||||
// Import agent files
|
||||
require("./coder");
|
||||
require("./pm");
|
||||
require("./marketing");
|
||||
require("./atlas");
|
||||
require("./import-analyzer");
|
||||
// Re-export public API
|
||||
var registry_1 = require("./registry");
|
||||
Object.defineProperty(exports, "AGENTS", { enumerable: true, get: function () { return registry_1.AGENTS; } });
|
||||
|
||||
44
vibn-agent-runner/dist/llm/gemini-chat.d.ts
vendored
Normal file
44
vibn-agent-runner/dist/llm/gemini-chat.d.ts
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
export interface ChatMessage {
|
||||
role: "user" | "assistant" | "tool";
|
||||
content: string;
|
||||
toolCalls?: ToolCall[];
|
||||
toolCallId?: string;
|
||||
toolName?: string;
|
||||
thoughtSignature?: string;
|
||||
}
|
||||
export interface ToolCall {
|
||||
id: string;
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
thoughtSignature?: string;
|
||||
}
|
||||
export interface ToolDefinition {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: Record<string, unknown>;
|
||||
}
|
||||
export interface ChatChunk {
|
||||
type: "text" | "thinking" | "tool_call" | "done" | "error";
|
||||
text?: string;
|
||||
toolCall?: ToolCall;
|
||||
error?: string;
|
||||
}
|
||||
export declare function callGeminiChat(opts: {
|
||||
systemPrompt: string;
|
||||
messages: ChatMessage[];
|
||||
tools?: ToolDefinition[];
|
||||
temperature?: number;
|
||||
includeThoughts?: boolean;
|
||||
}): Promise<{
|
||||
text: string;
|
||||
thoughts: string;
|
||||
toolCalls: ToolCall[];
|
||||
finishReason?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
export declare function streamGeminiChat(opts: {
|
||||
systemPrompt: string;
|
||||
messages: ChatMessage[];
|
||||
tools?: ToolDefinition[];
|
||||
temperature?: number;
|
||||
}): AsyncGenerator<ChatChunk>;
|
||||
159
vibn-agent-runner/dist/llm/gemini-chat.js
vendored
Normal file
159
vibn-agent-runner/dist/llm/gemini-chat.js
vendored
Normal file
@@ -0,0 +1,159 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.callGeminiChat = callGeminiChat;
|
||||
exports.streamGeminiChat = streamGeminiChat;
|
||||
const genai_1 = require("@google/genai");
|
||||
const GEMINI_API_KEY = process.env.GOOGLE_API_KEY || "";
|
||||
const GEMINI_MODEL = process.env.VIBN_CHAT_MODEL || "gemini-3.1-pro-preview";
|
||||
if (!GEMINI_API_KEY) {
|
||||
console.warn(`[GeminiChat] WARNING: GOOGLE_API_KEY is not set. Chat stream will fail with 403 Forbidden.`);
|
||||
}
|
||||
const ai = new genai_1.GoogleGenAI({ apiKey: GEMINI_API_KEY });
|
||||
function toGeminiContents(messages) {
|
||||
const contents = [];
|
||||
for (const msg of messages) {
|
||||
if (msg.role === "user") {
|
||||
contents.push({ role: "user", parts: [{ text: msg.content }] });
|
||||
}
|
||||
else if (msg.role === "assistant") {
|
||||
const parts = [];
|
||||
if (msg.content)
|
||||
parts.push({ text: msg.content });
|
||||
if (msg.toolCalls?.length) {
|
||||
for (const tc of msg.toolCalls) {
|
||||
const part = {
|
||||
functionCall: { name: tc.name, args: tc.args },
|
||||
};
|
||||
if (tc.thoughtSignature) {
|
||||
part.thoughtSignature = tc.thoughtSignature;
|
||||
}
|
||||
parts.push(part);
|
||||
}
|
||||
}
|
||||
if (parts.length)
|
||||
contents.push({ role: "model", parts });
|
||||
}
|
||||
else if (msg.role === "tool") {
|
||||
const part = {
|
||||
functionResponse: {
|
||||
name: msg.toolName || "unknown",
|
||||
response: { name: msg.toolName || "unknown", content: msg.content },
|
||||
},
|
||||
};
|
||||
const last = contents[contents.length - 1];
|
||||
if (last?.role === "user") {
|
||||
last.parts.push(part);
|
||||
}
|
||||
else {
|
||||
contents.push({ role: "user", parts: [part] });
|
||||
}
|
||||
}
|
||||
}
|
||||
return contents;
|
||||
}
|
||||
function toGeminiFunctions(tools) {
|
||||
if (!tools.length)
|
||||
return undefined;
|
||||
return [
|
||||
{
|
||||
functionDeclarations: tools.map((t) => ({
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
parameters: t.parameters,
|
||||
})),
|
||||
},
|
||||
];
|
||||
}
|
||||
async function callGeminiChat(opts) {
|
||||
try {
|
||||
const config = {
|
||||
temperature: opts.temperature ?? 0.7,
|
||||
maxOutputTokens: 8192,
|
||||
};
|
||||
if (opts.systemPrompt) {
|
||||
config.systemInstruction = opts.systemPrompt;
|
||||
}
|
||||
if (opts.includeThoughts) {
|
||||
config.thinkingConfig = { thinkingBudgetTokens: 1024 };
|
||||
}
|
||||
const fns = toGeminiFunctions(opts.tools ?? []);
|
||||
if (fns)
|
||||
config.tools = fns;
|
||||
const response = await ai.models.generateContent({
|
||||
model: GEMINI_MODEL,
|
||||
contents: toGeminiContents(opts.messages),
|
||||
config
|
||||
});
|
||||
let text = "";
|
||||
let thoughts = "";
|
||||
const toolCalls = [];
|
||||
const parts = response.candidates?.[0]?.content?.parts ?? [];
|
||||
for (const part of parts) {
|
||||
if (part.text) {
|
||||
if (part.thought)
|
||||
thoughts += part.text;
|
||||
else
|
||||
text += part.text;
|
||||
}
|
||||
if (part.functionCall) {
|
||||
toolCalls.push({
|
||||
id: `tc-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
name: part.functionCall.name || "",
|
||||
args: part.functionCall.args ?? {},
|
||||
thoughtSignature: part.thoughtSignature,
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
text,
|
||||
thoughts,
|
||||
toolCalls,
|
||||
finishReason: response.candidates?.[0]?.finishReason
|
||||
};
|
||||
}
|
||||
catch (error) {
|
||||
return {
|
||||
text: "",
|
||||
thoughts: "",
|
||||
toolCalls: [],
|
||||
error: `GoogleGenAI error: ${error instanceof Error ? error.message : String(error)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
async function* streamGeminiChat(opts) {
|
||||
try {
|
||||
const config = {
|
||||
temperature: opts.temperature ?? 0.7,
|
||||
maxOutputTokens: 8192,
|
||||
thinkingConfig: { thinkingBudgetTokens: 1024 },
|
||||
};
|
||||
if (opts.systemPrompt) {
|
||||
config.systemInstruction = opts.systemPrompt;
|
||||
}
|
||||
const fns = toGeminiFunctions(opts.tools ?? []);
|
||||
if (fns)
|
||||
config.tools = fns;
|
||||
const streamResult = await ai.models.generateContentStream({
|
||||
model: GEMINI_MODEL,
|
||||
contents: toGeminiContents(opts.messages),
|
||||
config
|
||||
});
|
||||
for await (const chunk of streamResult) {
|
||||
const parts = chunk.candidates?.[0]?.content?.parts ?? [];
|
||||
for (const part of parts) {
|
||||
if (part.text) {
|
||||
yield part.thought
|
||||
? { type: "thinking", text: part.text }
|
||||
: { type: "text", text: part.text };
|
||||
}
|
||||
}
|
||||
}
|
||||
yield { type: "done" };
|
||||
}
|
||||
catch (error) {
|
||||
yield {
|
||||
type: "error",
|
||||
error: `GoogleGenAI error: ${error instanceof Error ? error.message : String(error)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
27
vibn-agent-runner/dist/llm/openai-compatible-chat.d.ts
vendored
Normal file
27
vibn-agent-runner/dist/llm/openai-compatible-chat.d.ts
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* OpenAI Chat Completions-compatible backend (DeepSeek, etc.).
|
||||
*
|
||||
* DeepSeek: base URL + `/chat/completions`, Bearer key — see
|
||||
* https://api-docs.deepseek.com/
|
||||
*
|
||||
* Tool schemas in Vibn are authored for Gemini (uppercase type enums).
|
||||
* We normalize them to JSON Schema before sending.
|
||||
*/
|
||||
import type { ChatMessage, ToolCall, ToolDefinition } from "./gemini-chat";
|
||||
/**
|
||||
* Non-streaming chat + tool calls — mirrors {@link callGeminiChat} return shape.
|
||||
*/
|
||||
export declare function callOpenAiCompatibleChat(opts: {
|
||||
systemPrompt: string;
|
||||
messages: ChatMessage[];
|
||||
tools?: ToolDefinition[];
|
||||
temperature?: number;
|
||||
/** Unused for OpenAI-compat; kept for call-site symmetry */
|
||||
includeThoughts?: boolean;
|
||||
}): Promise<{
|
||||
text: string;
|
||||
thoughts: string;
|
||||
toolCalls: ToolCall[];
|
||||
finishReason?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
310
vibn-agent-runner/dist/llm/openai-compatible-chat.js
vendored
Normal file
310
vibn-agent-runner/dist/llm/openai-compatible-chat.js
vendored
Normal file
@@ -0,0 +1,310 @@
|
||||
"use strict";
|
||||
/**
|
||||
* OpenAI Chat Completions-compatible backend (DeepSeek, etc.).
|
||||
*
|
||||
* DeepSeek: base URL + `/chat/completions`, Bearer key — see
|
||||
* https://api-docs.deepseek.com/
|
||||
*
|
||||
* Tool schemas in Vibn are authored for Gemini (uppercase type enums).
|
||||
* We normalize them to JSON Schema before sending.
|
||||
*/
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.callOpenAiCompatibleChat = callOpenAiCompatibleChat;
|
||||
const DEFAULT_CHAT_URL = "https://api.deepseek.com/chat/completions";
|
||||
function resolveApiKey() {
|
||||
return (process.env.DEEPSEEK_API_KEY?.trim() ||
|
||||
process.env.VIBN_OPENAI_COMPATIBLE_API_KEY?.trim() ||
|
||||
"");
|
||||
}
|
||||
function resolveChatUrl() {
|
||||
const raw = process.env.VIBN_OPENAI_COMPATIBLE_CHAT_URL?.trim();
|
||||
if (raw)
|
||||
return raw.replace(/\/$/, "");
|
||||
const base = process.env.VIBN_OPENAI_COMPATIBLE_BASE_URL?.trim().replace(/\/$/, "");
|
||||
if (!base)
|
||||
return DEFAULT_CHAT_URL;
|
||||
if (base.endsWith("/chat/completions"))
|
||||
return base;
|
||||
return `${base}/chat/completions`;
|
||||
}
|
||||
function resolveModel() {
|
||||
return (process.env.VIBN_OPENAI_COMPATIBLE_MODEL?.trim() ||
|
||||
process.env.DEEPSEEK_MODEL?.trim() ||
|
||||
"deepseek-chat");
|
||||
}
|
||||
/** Gemini API Catalog-style schema → OpenAI JSON Schema */
|
||||
function geminiStyleToJsonSchema(node) {
|
||||
if (node === null || typeof node !== "object" || Array.isArray(node))
|
||||
return node;
|
||||
const n = node;
|
||||
const out = {};
|
||||
for (const [key, val] of Object.entries(n)) {
|
||||
if (key === "type" && typeof val === "string") {
|
||||
const map = {
|
||||
OBJECT: "object",
|
||||
STRING: "string",
|
||||
NUMBER: "number",
|
||||
INTEGER: "integer",
|
||||
BOOLEAN: "boolean",
|
||||
ARRAY: "array",
|
||||
};
|
||||
const upper = val.toUpperCase();
|
||||
out.type = map[upper] ?? val.toLowerCase();
|
||||
continue;
|
||||
}
|
||||
if (key === "properties" &&
|
||||
val &&
|
||||
typeof val === "object" &&
|
||||
!Array.isArray(val)) {
|
||||
out.properties = Object.fromEntries(Object.entries(val).map(([k, v]) => [
|
||||
k,
|
||||
geminiStyleToJsonSchema(v),
|
||||
]));
|
||||
continue;
|
||||
}
|
||||
if (key === "items") {
|
||||
out.items = geminiStyleToJsonSchema(val);
|
||||
continue;
|
||||
}
|
||||
out[key] =
|
||||
val && typeof val === "object" && !Array.isArray(val)
|
||||
? geminiStyleToJsonSchema(val)
|
||||
: val;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
function toOpenAiTools(tools) {
|
||||
if (!tools?.length)
|
||||
return undefined;
|
||||
return tools.map((t) => ({
|
||||
type: "function",
|
||||
function: {
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
parameters: geminiStyleToJsonSchema(t.parameters),
|
||||
},
|
||||
}));
|
||||
}
|
||||
/**
|
||||
* OpenAI Chat Completions forbid `user`/`assistant` between an assistant
|
||||
* `tool_calls` block and the matching `tool` replies. Gemini-oriented code
|
||||
* may inject recovery `user` rows between individual tool results — move
|
||||
* those users to immediately after all tool rows for that assistant turn.
|
||||
*/
|
||||
function reorderMessagesForOpenAiToolPairs(messages) {
|
||||
const result = [];
|
||||
let i = 0;
|
||||
while (i < messages.length) {
|
||||
const m = messages[i];
|
||||
if (m.role !== "assistant" || !m.toolCalls?.length) {
|
||||
result.push(m);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
const expectedIds = m.toolCalls.map((tc) => tc.id);
|
||||
const pending = new Set(expectedIds);
|
||||
result.push(m);
|
||||
i++;
|
||||
const toolById = new Map();
|
||||
const bufferedUsers = [];
|
||||
while (i < messages.length && pending.size > 0) {
|
||||
const n = messages[i];
|
||||
if (n.role === "tool" && n.toolCallId && pending.has(n.toolCallId)) {
|
||||
toolById.set(n.toolCallId, n);
|
||||
pending.delete(n.toolCallId);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (n.role === "user") {
|
||||
bufferedUsers.push(n);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
for (const id of expectedIds) {
|
||||
const t = toolById.get(id);
|
||||
if (t)
|
||||
result.push(t);
|
||||
}
|
||||
result.push(...bufferedUsers);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
function toOpenAiMessages(systemPrompt, messages) {
|
||||
const normalized = reorderMessagesForOpenAiToolPairs(messages);
|
||||
const out = [{ role: "system", content: systemPrompt }];
|
||||
for (const m of normalized) {
|
||||
if (m.role === "user") {
|
||||
out.push({ role: "user", content: m.content });
|
||||
}
|
||||
else if (m.role === "assistant") {
|
||||
const hasTools = Boolean(m.toolCalls?.length);
|
||||
const text = typeof m.content === "string" ? m.content.trim() : "";
|
||||
const msg = {
|
||||
role: "assistant",
|
||||
content: text.length > 0 ? m.content : hasTools ? null : "",
|
||||
};
|
||||
if (hasTools && m.toolCalls) {
|
||||
msg.tool_calls = m.toolCalls.map((tc) => ({
|
||||
id: tc.id,
|
||||
type: "function",
|
||||
function: {
|
||||
name: tc.name,
|
||||
arguments: JSON.stringify(tc.args ?? {}),
|
||||
},
|
||||
}));
|
||||
}
|
||||
out.push(msg);
|
||||
}
|
||||
else if (m.role === "tool") {
|
||||
const body = typeof m.content === "string"
|
||||
? m.content
|
||||
: JSON.stringify(m.content ?? "");
|
||||
out.push({
|
||||
role: "tool",
|
||||
tool_call_id: m.toolCallId ?? "",
|
||||
content: body.length > 0 ? body : "(empty)",
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
function parseAssistantMessage(message) {
|
||||
const rawText = typeof message?.content === "string" ? message.content : "";
|
||||
const thoughts = typeof message?.reasoning_content === "string"
|
||||
? message.reasoning_content
|
||||
: typeof message?.reasoning === "string"
|
||||
? message.reasoning
|
||||
: "";
|
||||
const stripTags = (s) => s
|
||||
.replace(/<tool_calls>[\s\S]*?<\/tool_calls>/g, "")
|
||||
.replace(/<think>[\s\S]*?<\/think>/g, "")
|
||||
.trim();
|
||||
// DeepSeek separates thinking from speaking — during tool loops it
|
||||
// often puts everything in reasoning_content and leaves content empty.
|
||||
// When that happens, surface the reasoning as the user-visible text
|
||||
// so the user isn't staring at silent tool pills.
|
||||
const text = stripTags(rawText || thoughts);
|
||||
const toolCalls = [];
|
||||
const rawCalls = message?.tool_calls;
|
||||
if (Array.isArray(rawCalls)) {
|
||||
for (const c of rawCalls) {
|
||||
const call = c;
|
||||
if (call.type !== "function")
|
||||
continue;
|
||||
const fn = call.function;
|
||||
const name = typeof fn?.name === "string" ? fn.name : "";
|
||||
const id = typeof call.id === "string"
|
||||
? call.id
|
||||
: `tc-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
let args = {};
|
||||
const argStr = typeof fn?.arguments === "string" ? fn.arguments : "{}";
|
||||
try {
|
||||
args = JSON.parse(argStr || "{}");
|
||||
}
|
||||
catch {
|
||||
args = {};
|
||||
}
|
||||
if (name)
|
||||
toolCalls.push({ id, name, args });
|
||||
}
|
||||
}
|
||||
return { text, thoughts, toolCalls };
|
||||
}
|
||||
/**
|
||||
* Non-streaming chat + tool calls — mirrors {@link callGeminiChat} return shape.
|
||||
*/
|
||||
async function callOpenAiCompatibleChat(opts) {
|
||||
const apiKey = resolveApiKey();
|
||||
if (!apiKey) {
|
||||
return {
|
||||
text: "",
|
||||
thoughts: "",
|
||||
toolCalls: [],
|
||||
error: "No API key: set DEEPSEEK_API_KEY or VIBN_OPENAI_COMPATIBLE_API_KEY for OpenAI-compatible chat.",
|
||||
};
|
||||
}
|
||||
const url = resolveChatUrl();
|
||||
const model = resolveModel();
|
||||
const tools = toOpenAiTools(opts.tools);
|
||||
const oaiMessages = toOpenAiMessages(opts.systemPrompt, opts.messages);
|
||||
const body = {
|
||||
model,
|
||||
messages: oaiMessages,
|
||||
temperature: opts.temperature ?? 0.7,
|
||||
max_tokens: 8192,
|
||||
stream: false,
|
||||
};
|
||||
if (tools?.length)
|
||||
body.tools = tools;
|
||||
// ── Request logging (DeepSeek 400 debug) ──────────────────────────────
|
||||
const msgSummary = oaiMessages.map((m) => ({
|
||||
role: m.role,
|
||||
has_tool_calls: m.role === "assistant" ? Boolean(m.tool_calls?.length) : undefined,
|
||||
tool_calls_ids: m.role === "assistant" && m.tool_calls?.length
|
||||
? m.tool_calls.map((tc) => tc.id)
|
||||
: undefined,
|
||||
tool_call_id: m.role === "tool" ? m.tool_call_id : undefined,
|
||||
content_len: typeof m.content === "string" ? m.content.length : 0,
|
||||
}));
|
||||
console.error("[deepseek] request", JSON.stringify({
|
||||
url,
|
||||
model,
|
||||
msg_count: oaiMessages.length,
|
||||
has_tools: Boolean(tools?.length),
|
||||
tool_count: tools?.length ?? 0,
|
||||
msg_summary: msgSummary,
|
||||
last_5_roles: msgSummary.slice(-5).map((m) => m.role),
|
||||
}));
|
||||
// ───────────────────────────────────────────────────────────────────────
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
catch (e) {
|
||||
return {
|
||||
text: "",
|
||||
thoughts: "",
|
||||
toolCalls: [],
|
||||
error: `Network error: ${e instanceof Error ? e.message : String(e)}`,
|
||||
};
|
||||
}
|
||||
const data = (await res.json().catch(() => ({})));
|
||||
if (!res.ok) {
|
||||
// ── Error logging (DeepSeek 400 debug) ───────────────────────────────
|
||||
console.error("[deepseek] error response", JSON.stringify({
|
||||
status: res.status,
|
||||
status_text: res.statusText,
|
||||
headers: Object.fromEntries(res.headers.entries()),
|
||||
body: data,
|
||||
// include the last few messages sent so we can see the exact
|
||||
// pattern that triggered the error
|
||||
last_5_sent: msgSummary.slice(-5),
|
||||
}, null, 2));
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
const errObj = data?.error;
|
||||
const msg = (typeof errObj?.message === "string" && errObj.message) ||
|
||||
JSON.stringify(data).slice(0, 280);
|
||||
return {
|
||||
text: "",
|
||||
thoughts: "",
|
||||
toolCalls: [],
|
||||
error: `Chat API error ${res.status}: ${msg}`,
|
||||
};
|
||||
}
|
||||
const choice = data.choices?.[0];
|
||||
const message = choice?.message;
|
||||
const { text, thoughts, toolCalls } = parseAssistantMessage(message);
|
||||
const finishReason = typeof choice?.finish_reason === "string"
|
||||
? choice.finish_reason
|
||||
: undefined;
|
||||
return { text, thoughts, toolCalls, finishReason };
|
||||
}
|
||||
28
vibn-agent-runner/dist/llm/vibn-chat-model.d.ts
vendored
Normal file
28
vibn-agent-runner/dist/llm/vibn-chat-model.d.ts
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Routes workspace AI chat to Gemini or an OpenAI-compatible API (e.g. DeepSeek).
|
||||
*
|
||||
* Env:
|
||||
* VIBN_CHAT_PROVIDER=gemini | deepseek | openai_compatible
|
||||
*
|
||||
* Default: gemini (requires GOOGLE_API_KEY / studio key + VIBN_CHAT_MODEL).
|
||||
*
|
||||
* DeepSeek / OpenAI-compat:
|
||||
* DEEPSEEK_API_KEY (or VIBN_OPENAI_COMPATIBLE_API_KEY)
|
||||
* Optional: VIBN_OPENAI_COMPATIBLE_CHAT_URL (default https://api.deepseek.com/chat/completions)
|
||||
* Optional: VIBN_OPENAI_COMPATIBLE_MODEL (default deepseek-chat)
|
||||
*/
|
||||
import type { ChatMessage, ToolDefinition } from './gemini-chat';
|
||||
export type VibnChatCallOpts = {
|
||||
systemPrompt: string;
|
||||
messages: ChatMessage[];
|
||||
tools?: ToolDefinition[];
|
||||
temperature?: number;
|
||||
includeThoughts?: boolean;
|
||||
};
|
||||
export declare function callVibnChat(opts: VibnChatCallOpts): Promise<{
|
||||
text: string;
|
||||
thoughts: string;
|
||||
toolCalls: import("./gemini-chat").ToolCall[];
|
||||
finishReason?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
25
vibn-agent-runner/dist/llm/vibn-chat-model.js
vendored
Normal file
25
vibn-agent-runner/dist/llm/vibn-chat-model.js
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
"use strict";
|
||||
/**
|
||||
* Routes workspace AI chat to Gemini or an OpenAI-compatible API (e.g. DeepSeek).
|
||||
*
|
||||
* Env:
|
||||
* VIBN_CHAT_PROVIDER=gemini | deepseek | openai_compatible
|
||||
*
|
||||
* Default: gemini (requires GOOGLE_API_KEY / studio key + VIBN_CHAT_MODEL).
|
||||
*
|
||||
* DeepSeek / OpenAI-compat:
|
||||
* DEEPSEEK_API_KEY (or VIBN_OPENAI_COMPATIBLE_API_KEY)
|
||||
* Optional: VIBN_OPENAI_COMPATIBLE_CHAT_URL (default https://api.deepseek.com/chat/completions)
|
||||
* Optional: VIBN_OPENAI_COMPATIBLE_MODEL (default deepseek-chat)
|
||||
*/
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.callVibnChat = callVibnChat;
|
||||
const gemini_chat_1 = require("./gemini-chat");
|
||||
const openai_compatible_chat_1 = require("./openai-compatible-chat");
|
||||
async function callVibnChat(opts) {
|
||||
const p = (process.env.VIBN_CHAT_PROVIDER || 'gemini').toLowerCase().trim();
|
||||
if (p === 'deepseek' || p === 'openai_compatible') {
|
||||
return (0, openai_compatible_chat_1.callOpenAiCompatibleChat)(opts);
|
||||
}
|
||||
return (0, gemini_chat_1.callGeminiChat)(opts);
|
||||
}
|
||||
331
vibn-agent-runner/dist/server.js
vendored
331
vibn-agent-runner/dist/server.js
vendored
@@ -40,15 +40,9 @@ const express_1 = __importDefault(require("express"));
|
||||
const cors_1 = __importDefault(require("cors"));
|
||||
const fs = __importStar(require("fs"));
|
||||
const path = __importStar(require("path"));
|
||||
const crypto = __importStar(require("crypto"));
|
||||
const child_process_1 = require("child_process");
|
||||
const job_store_1 = require("./job-store");
|
||||
const agent_runner_1 = require("./agent-runner");
|
||||
const agent_session_runner_1 = require("./agent-session-runner");
|
||||
const agents_1 = require("./agents");
|
||||
const orchestrator_1 = require("./orchestrator");
|
||||
const atlas_1 = require("./atlas");
|
||||
const llm_1 = require("./llm");
|
||||
const app = (0, express_1.default)();
|
||||
app.use((0, cors_1.default)());
|
||||
const startTime = new Date();
|
||||
@@ -181,217 +175,6 @@ app.get('/api/agents', (_req, res) => {
|
||||
}));
|
||||
res.json(agents);
|
||||
});
|
||||
// Get server status and job statistics
|
||||
app.get('/api/status', (_req, res) => {
|
||||
const allJobs = (0, job_store_1.listJobs)(Infinity);
|
||||
const total_jobs = allJobs.length;
|
||||
const by_status = {
|
||||
queued: 0,
|
||||
running: 0,
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
};
|
||||
for (const job of allJobs) {
|
||||
by_status[job.status] = (by_status[job.status] || 0) + 1;
|
||||
}
|
||||
const uptime_seconds = Math.floor((new Date().getTime() - startTime.getTime()) / 1000);
|
||||
const agents = Object.values(agents_1.AGENTS).map(a => a.name);
|
||||
res.json({
|
||||
total_jobs,
|
||||
by_status,
|
||||
uptime_seconds,
|
||||
agents,
|
||||
});
|
||||
});
|
||||
// Submit a new job
|
||||
app.post('/api/agent/run', async (req, res) => {
|
||||
const { agent: agentName, task, repo } = req.body;
|
||||
if (!agentName || !task) {
|
||||
res.status(400).json({ error: '"agent" and "task" are required' });
|
||||
return;
|
||||
}
|
||||
const agentConfig = agents_1.AGENTS[agentName];
|
||||
if (!agentConfig) {
|
||||
const available = Object.keys(agents_1.AGENTS).join(', ');
|
||||
res.status(400).json({ error: `Unknown agent "${agentName}". Available: ${available}` });
|
||||
return;
|
||||
}
|
||||
const job = (0, job_store_1.createJob)(agentName, task, repo);
|
||||
res.status(202).json({ jobId: job.id, status: job.status });
|
||||
// Run agent asynchronously
|
||||
const ctx = buildContext(repo);
|
||||
(0, agent_runner_1.runAgent)(job, agentConfig, task, ctx)
|
||||
.then(result => {
|
||||
(0, job_store_1.updateJob)(job.id, {
|
||||
status: 'completed',
|
||||
result: result.finalText,
|
||||
progress: `Done — ${result.turns} turns, ${result.toolCallCount} tool calls`
|
||||
});
|
||||
})
|
||||
.catch(err => {
|
||||
(0, job_store_1.updateJob)(job.id, {
|
||||
status: 'failed',
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
progress: 'Agent failed'
|
||||
});
|
||||
});
|
||||
});
|
||||
// Check job status
|
||||
app.get('/api/jobs/:id', (req, res) => {
|
||||
const job = (0, job_store_1.getJob)(req.params.id);
|
||||
if (!job) {
|
||||
res.status(404).json({ error: 'Job not found' });
|
||||
return;
|
||||
}
|
||||
res.json(job);
|
||||
});
|
||||
// ---------------------------------------------------------------------------
|
||||
// Orchestrator — persistent chat with full project context
|
||||
// ---------------------------------------------------------------------------
|
||||
app.post('/orchestrator/chat', async (req, res) => {
|
||||
const { message, session_id, history, knowledge_context } = req.body;
|
||||
if (!message) {
|
||||
res.status(400).json({ error: '"message" is required' });
|
||||
return;
|
||||
}
|
||||
const sessionId = session_id || `session_${Date.now()}`;
|
||||
const ctx = buildContext();
|
||||
try {
|
||||
const result = await (0, orchestrator_1.orchestratorChat)(sessionId, message, ctx, {
|
||||
preloadedHistory: history,
|
||||
knowledgeContext: knowledge_context
|
||||
});
|
||||
res.json(result);
|
||||
}
|
||||
catch (err) {
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
});
|
||||
app.get('/orchestrator/sessions', (_req, res) => {
|
||||
res.json((0, orchestrator_1.listSessions)());
|
||||
});
|
||||
app.delete('/orchestrator/sessions/:id', (req, res) => {
|
||||
(0, orchestrator_1.clearSession)(req.params.id);
|
||||
res.json({ cleared: req.params.id });
|
||||
});
|
||||
// ---------------------------------------------------------------------------
|
||||
// Atlas — PRD discovery agent
|
||||
// ---------------------------------------------------------------------------
|
||||
app.post('/atlas/chat', async (req, res) => {
|
||||
const { message, session_id, history, is_init, } = req.body;
|
||||
if (!message) {
|
||||
res.status(400).json({ error: '"message" is required' });
|
||||
return;
|
||||
}
|
||||
const sessionId = session_id || `atlas_${Date.now()}`;
|
||||
const ctx = buildContext();
|
||||
try {
|
||||
const result = await (0, atlas_1.atlasChat)(sessionId, message, ctx, {
|
||||
preloadedHistory: history,
|
||||
isInit: is_init,
|
||||
});
|
||||
res.json(result);
|
||||
}
|
||||
catch (err) {
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
});
|
||||
app.get('/atlas/sessions', (_req, res) => {
|
||||
res.json((0, atlas_1.listAtlasSessions)());
|
||||
});
|
||||
app.delete('/atlas/sessions/:id', (req, res) => {
|
||||
(0, atlas_1.clearAtlasSession)(req.params.id);
|
||||
res.json({ cleared: req.params.id });
|
||||
});
|
||||
// List recent jobs
|
||||
app.get('/api/jobs', (req, res) => {
|
||||
const limit = parseInt(req.query.limit || '20', 10);
|
||||
res.json((0, job_store_1.listJobs)(limit));
|
||||
});
|
||||
// Gitea webhook endpoint — triggers agent from an issue event
|
||||
app.post('/webhook/gitea', (req, res) => {
|
||||
const event = req.headers['x-gitea-event'];
|
||||
const rawBody = req.body;
|
||||
// Verify HMAC-SHA256 signature
|
||||
const webhookSecret = process.env.WEBHOOK_SECRET;
|
||||
if (webhookSecret) {
|
||||
const sig = req.headers['x-gitea-signature'];
|
||||
const expected = crypto
|
||||
.createHmac('sha256', webhookSecret)
|
||||
.update(rawBody)
|
||||
.digest('hex');
|
||||
if (!sig || !crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
|
||||
res.status(401).json({ error: 'Invalid webhook signature' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
const body = JSON.parse(rawBody.toString('utf8'));
|
||||
let task = null;
|
||||
let agentName = 'Coder';
|
||||
let repo;
|
||||
if (event === 'issues' && body.action === 'opened') {
|
||||
const issue = body.issue;
|
||||
repo = `${body.repository?.owner?.login}/${body.repository?.name}`;
|
||||
const labels = (issue.labels || []).map((l) => l.name);
|
||||
if (labels.includes('agent:pm')) {
|
||||
agentName = 'PM';
|
||||
}
|
||||
else if (labels.includes('agent:marketing')) {
|
||||
agentName = 'Marketing';
|
||||
}
|
||||
else if (labels.includes('agent:coder')) {
|
||||
agentName = 'Coder';
|
||||
}
|
||||
else {
|
||||
// No agent label — ignore
|
||||
res.json({ ignored: true, reason: 'no agent label on issue' });
|
||||
return;
|
||||
}
|
||||
task = `You have been assigned to resolve a Gitea issue in the repo ${repo}.\n\nIssue #${issue.number}: ${issue.title}\n\nDescription:\n${issue.body || '(no description)'}\n\nWhen done, close the issue by calling gitea_close_issue.`;
|
||||
}
|
||||
else if (event === 'push') {
|
||||
res.json({ ignored: true, reason: 'push events not auto-processed' });
|
||||
return;
|
||||
}
|
||||
else {
|
||||
res.json({ ignored: true, event });
|
||||
return;
|
||||
}
|
||||
if (!task) {
|
||||
res.json({ ignored: true });
|
||||
return;
|
||||
}
|
||||
const agentConfig = agents_1.AGENTS[agentName];
|
||||
const job = (0, job_store_1.createJob)(agentName, task, repo);
|
||||
res.status(202).json({ jobId: job.id, agent: agentName, event });
|
||||
const ctx = buildContext(repo);
|
||||
(0, agent_runner_1.runAgent)(job, agentConfig, task, ctx)
|
||||
.then(result => {
|
||||
(0, job_store_1.updateJob)(job.id, {
|
||||
status: 'completed',
|
||||
result: result.finalText,
|
||||
progress: `Done — ${result.turns} turns, ${result.toolCallCount} tool calls`
|
||||
});
|
||||
})
|
||||
.catch(err => {
|
||||
(0, job_store_1.updateJob)(job.id, {
|
||||
status: 'failed',
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
progress: 'Agent failed'
|
||||
});
|
||||
});
|
||||
});
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent Execute — VIBN Build > Code > Agent tab
|
||||
//
|
||||
// Receives a task from the VIBN frontend, runs the Coder agent against
|
||||
// the project's Gitea repo, and streams progress back to the VIBN DB
|
||||
// via PATCH /api/projects/[id]/agent/sessions/[sid].
|
||||
//
|
||||
// This endpoint returns immediately (202) and runs the agent async so
|
||||
// the browser can close without killing the loop.
|
||||
// ---------------------------------------------------------------------------
|
||||
// Track active sessions for stop support
|
||||
const activeSessions = new Map();
|
||||
app.post('/agent/execute', async (req, res) => {
|
||||
const { sessionId, projectId, appName, appPath, giteaRepo, task, continueTask, autoApprove, coolifyAppUuid, } = req.body;
|
||||
@@ -482,122 +265,12 @@ app.post('/agent/stop', (req, res) => {
|
||||
const session = activeSessions.get(sessionId);
|
||||
if (session) {
|
||||
session.stopped = true;
|
||||
res.json({ ok: true, message: 'Stop signal sent — agent will halt after current step.' });
|
||||
res.json({ status: 'stopped' });
|
||||
}
|
||||
else {
|
||||
res.json({ ok: true, message: 'Session not active (may have already completed).' });
|
||||
res.status(404).json({ error: 'session not found or not running' });
|
||||
}
|
||||
});
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent Approve — commit and push agent's changes to Gitea, trigger deploy
|
||||
//
|
||||
// Called by vibn-frontend after the user reviews changed files and clicks
|
||||
// "Approve & commit". The agent runner does git add/commit/push in the
|
||||
// workspace where the agent was working.
|
||||
// ---------------------------------------------------------------------------
|
||||
app.post('/agent/approve', async (req, res) => {
|
||||
const { giteaRepo, commitMessage, coolifyApiUrl, coolifyApiToken, coolifyAppUuid } = req.body;
|
||||
if (!giteaRepo || !commitMessage) {
|
||||
res.status(400).json({ error: 'giteaRepo and commitMessage are required' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Resolve the workspace root for this repo (does NOT re-clone if already present)
|
||||
const workspaceRoot = ensureWorkspace(giteaRepo);
|
||||
// Configure git identity for this commit
|
||||
const gitea = {
|
||||
username: process.env.GITEA_USERNAME || 'agent',
|
||||
apiToken: process.env.GITEA_API_TOKEN || '',
|
||||
apiUrl: process.env.GITEA_API_URL || '',
|
||||
};
|
||||
const { execSync: exec } = require('child_process');
|
||||
const gitOpts = { cwd: workspaceRoot, stdio: 'pipe' };
|
||||
// Ensure git identity
|
||||
try {
|
||||
exec('git config user.email "agent@vibnai.com"', gitOpts);
|
||||
exec('git config user.name "VIBN Agent"', gitOpts);
|
||||
}
|
||||
catch { /* already set */ }
|
||||
// Stage all changes
|
||||
exec('git add -A', gitOpts);
|
||||
// Check if there is anything to commit
|
||||
let status;
|
||||
try {
|
||||
status = exec('git status --porcelain', gitOpts).toString().trim();
|
||||
}
|
||||
catch {
|
||||
status = '';
|
||||
}
|
||||
if (!status) {
|
||||
res.json({ ok: true, committed: false, message: 'Nothing to commit — working tree is clean.' });
|
||||
return;
|
||||
}
|
||||
// Commit
|
||||
exec(`git commit -m ${JSON.stringify(commitMessage)}`, gitOpts);
|
||||
// Push — use token auth embedded in remote URL
|
||||
const authedUrl = `${gitea.apiUrl}/${giteaRepo}.git`
|
||||
.replace('https://', `https://${gitea.username}:${gitea.apiToken}@`);
|
||||
exec(`git push "${authedUrl}" HEAD:main`, gitOpts);
|
||||
// Optionally trigger a Coolify redeploy
|
||||
let deployed = false;
|
||||
if (coolifyApiUrl && coolifyApiToken && coolifyAppUuid) {
|
||||
try {
|
||||
const deployRes = await fetch(`${coolifyApiUrl}/api/v1/applications/${coolifyAppUuid}/start`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${coolifyApiToken}` },
|
||||
});
|
||||
deployed = deployRes.ok;
|
||||
}
|
||||
catch { /* deploy trigger is best-effort */ }
|
||||
}
|
||||
res.json({ ok: true, committed: true, deployed, message: `Committed and pushed: "${commitMessage}"` });
|
||||
}
|
||||
catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error('[agent/approve]', msg);
|
||||
res.status(500).json({ error: msg });
|
||||
}
|
||||
});
|
||||
// ---------------------------------------------------------------------------
|
||||
// Generate — thin structured-generation endpoint (no session, no system prompt)
|
||||
// Use this for one-shot tasks like architecture recommendations.
|
||||
// ---------------------------------------------------------------------------
|
||||
app.post('/generate', async (req, res) => {
|
||||
const { prompt, model, region } = req.body;
|
||||
if (!prompt) {
|
||||
res.status(400).json({ error: '"prompt" is required' });
|
||||
return;
|
||||
}
|
||||
// Allow overriding CLAUDE_REGION per-request for testing
|
||||
const prevRegion = process.env.CLAUDE_REGION;
|
||||
if (region)
|
||||
process.env.CLAUDE_REGION = region;
|
||||
try {
|
||||
const llm = (0, llm_1.createLLM)(model ?? 'A', { temperature: 0.3 });
|
||||
const messages = [
|
||||
{ role: 'user', content: prompt }
|
||||
];
|
||||
const response = await llm.chat(messages, [], 8192);
|
||||
res.json({ reply: response.content ?? '', model: llm.modelId });
|
||||
}
|
||||
catch (err) {
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : String(err), model });
|
||||
}
|
||||
finally {
|
||||
if (region)
|
||||
process.env.CLAUDE_REGION = prevRegion ?? '';
|
||||
}
|
||||
});
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error handler
|
||||
// ---------------------------------------------------------------------------
|
||||
app.use((err, _req, res, _next) => {
|
||||
console.error(err.stack);
|
||||
res.status(500).json({ error: err.message });
|
||||
});
|
||||
// ---------------------------------------------------------------------------
|
||||
// Start
|
||||
// ---------------------------------------------------------------------------
|
||||
app.listen(PORT, () => {
|
||||
console.log(`AgentRunner listening on port ${PORT}`);
|
||||
console.log(`Agents available: ${Object.keys(agents_1.AGENTS).join(', ')}`);
|
||||
|
||||
Reference in New Issue
Block a user