fix(runner): remove leftover syntax errors

This commit is contained in:
2026-05-19 14:34:42 -07:00
parent 2f86a4262e
commit 8071ac9049
41 changed files with 1539 additions and 2670 deletions

View File

@@ -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" });
}
}

View File

@@ -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
});

View File

@@ -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';

View File

@@ -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; } });

View 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>;

View 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)}`,
};
}
}

View 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;
}>;

View 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 };
}

View 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;
}>;

View 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);
}

View File

@@ -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(', ')}`);