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

View File

@@ -0,0 +1,7 @@
const fs = require('fs');
// Fix gemini-chat.ts name error
let geminiCode = fs.readFileSync('src/llm/gemini-chat.ts', 'utf8');
geminiCode = geminiCode.replace('name: part.functionCall.name,', 'name: part.functionCall.name || "",');
fs.writeFileSync('src/llm/gemini-chat.ts', geminiCode);

View File

@@ -0,0 +1,17 @@
const fs = require('fs');
// 1. Remove status and listJobs from server.ts
let serverCode = fs.readFileSync('src/server.ts', 'utf8');
serverCode = serverCode.replace(/app\.get\('\/api\/status'[\s\S]*?\}\);\n/g, "");
fs.writeFileSync('src/server.ts', serverCode);
// 2. Fix agent-session-runner.ts
let runnerCode = fs.readFileSync('src/agent-session-runner.ts', 'utf8');
runnerCode = runnerCode.replace(/import \{ createLLM, toOAITools, LLMMessage \} from "\.\/llm";/g, 'import { callVibnChat } from "./llm/vibn-chat-model";\nimport { ChatMessage } from "./llm/gemini-chat";');
runnerCode = runnerCode.replace(/import \{ ingestSessionEvents \} from "\.\/vibn-events-ingest";/g, '');
runnerCode = runnerCode.replace(/ingestSessionEvents\([\s\S]*?\),/g, '');
// Since the whole loop needs to use the new frontend loop,
// I should just replace the loop in runSessionAgent with a placeholder
// for now to make it compile, then we can copy the actual loop.

View File

@@ -0,0 +1,32 @@
const fs = require('fs');
let runnerCode = fs.readFileSync('src/agent-session-runner.ts', 'utf8');
// Replace the llm logic to match the frontend
const oldLogic = ` const llm = createLLM(config.model, { temperature: 0.2 });
const oaiTools = toOAITools(config.tools);`;
const newLogic = ` const systemPrompt = resolvePrompt(config.promptId);`;
runnerCode = runnerCode.replace(oldLogic, newLogic);
// Replace the emit logic to remove ingestSessionEvents
const oldEmit = ` await Promise.all([
patchSession(opts, { outputLine: line }),
ingestSessionEvents(opts.vibnApiUrl, opts.projectId, opts.sessionId, [
{
type: \`output.\${line.type}\`,
payload: { text: line.text },
ts: line.ts,
},
]),
]);`;
const newEmit = ` await patchSession(opts, { outputLine: line });`;
runnerCode = runnerCode.replace(oldEmit, newEmit);
runnerCode = runnerCode.replace(/import \{ createLLM, toOAITools, LLMMessage \} from "\.\/llm";/g, 'import { callVibnChat } from "./llm/vibn-chat-model";\nimport { ChatMessage } from "./llm/gemini-chat";');
runnerCode = runnerCode.replace(/import \{ ingestSessionEvents \} from "\.\/vibn-events-ingest";/g, '');
runnerCode = runnerCode.replace(/const history: LLMMessage\[\]/g, 'const history: ChatMessage[]');
fs.writeFileSync('src/agent-session-runner.ts', runnerCode);
console.log("Patched agent-session-runner.ts");

View File

@@ -0,0 +1,15 @@
const fs = require('fs');
let code = fs.readFileSync('src/server.ts', 'utf8');
code = code.replace(
` by_status,
uptime_seconds,
agents,
});`,
` by_status,
uptime_seconds,
agents,
});
});`
);
fs.writeFileSync('src/server.ts', code);

View File

@@ -0,0 +1,21 @@
const fs = require('fs');
let code = fs.readFileSync('src/server.ts', 'utf8');
code = code.replace(
`app.post('/agent/stop', (req: Request, res: Response) => {
const { sessionId } = req.body as { sessionId?: string };
if (!sessionId) { res.status(400).json({ error: 'sessionId required' });`,
`app.post('/agent/stop', (req: Request, res: Response) => {
const { sessionId } = req.body as { sessionId?: string };
if (!sessionId) { res.status(400).json({ error: 'sessionId required' }); return; }
const session = activeSessions.get(sessionId);
if (session) {
session.stopped = true;
res.json({ status: 'stopped' });
} else {
res.status(404).json({ error: 'session not found or not running' });
}
});`
);
fs.writeFileSync('src/server.ts', code);

View File

@@ -0,0 +1,32 @@
const fs = require('fs');
const file = 'src/server.ts';
let code = fs.readFileSync(file, 'utf8');
// Remove imports of orchestrator, atlas, etc.
code = code.replace(/import \{ orchestratorChat.*?\n/g, "");
code = code.replace(/import \{ atlasChat.*?\n/g, "");
code = code.replace(/import \{ createJob.*?\n/g, "");
code = code.replace(/import \{ runAgent \}.*?\n/g, "");
code = code.replace(/import \{ LLMMessage, createLLM \}.*?\n/g, "");
code = code.replace(/import \{ ingestSessionEvents \}.*?\n/g, "");
// Cut everything between app.get('/api/status'...) and app.post('/agent/execute'...)
const statusStart = code.indexOf("app.get('/api/status'");
const execStart = code.indexOf("app.post('/agent/execute'");
if (statusStart > -1 && execStart > -1) {
const toKeep = code.substring(statusStart, code.indexOf("});", statusStart) + 3);
code = code.substring(0, statusStart) + toKeep + "\n\nconst activeSessions = new Map<string, { stopped: boolean }>();\n\n" + code.substring(execStart);
}
// Cut everything between app.post('/agent/stop'...) and app.listen(...)
const stopStart = code.indexOf("app.post('/agent/stop'");
const listenStart = code.indexOf("app.listen(");
if (stopStart > -1 && listenStart > -1) {
const toKeepStop = code.substring(stopStart, code.indexOf("});", stopStart) + 3);
code = code.substring(0, stopStart) + toKeepStop + "\n\n" + code.substring(listenStart);
}
fs.writeFileSync(file, code);
console.log("Server.ts cleaned up.");

View File

@@ -1,104 +0,0 @@
import { createLLM, toOAITools, LLMMessage } from './llm';
import { AgentConfig } from './agents';
import { executeTool, ToolContext } from './tools';
import { resolvePrompt } from './prompts/loader';
import { Job, updateJob } from './job-store';
const MAX_TURNS = 40;
export interface RunResult {
finalText: string;
toolCallCount: number;
turns: number;
model: string;
}
/**
* Core agent execution loop — model-agnostic via the unified LLM client.
*
* Agents use their configured model tier (A/B/C) or a specific model ID.
* Tool calling uses OpenAI format throughout.
*/
export async function runAgent(
job: Job,
config: AgentConfig,
task: string,
ctx: ToolContext
): Promise<RunResult> {
const llm = createLLM(config.model, { temperature: 0.2 });
const oaiTools = toOAITools(config.tools);
const history: LLMMessage[] = [
{ role: 'user', content: task }
];
let toolCallCount = 0;
let turn = 0;
let finalText = '';
updateJob(job.id, { status: 'running', progress: `Starting ${config.name} (${llm.modelId})…` });
while (turn < MAX_TURNS) {
turn++;
const systemPrompt = resolvePrompt(config.promptId);
const messages: LLMMessage[] = [
{ role: 'system', content: systemPrompt },
...history
];
const response = await llm.chat(messages, oaiTools, 8192);
// Build assistant message for history
const assistantMsg: LLMMessage = {
role: 'assistant',
content: response.content,
tool_calls: response.tool_calls.length > 0 ? response.tool_calls : undefined
};
history.push(assistantMsg);
// No tool calls — agent is done
if (response.tool_calls.length === 0) {
finalText = response.content ?? '';
break;
}
// Execute tool calls
for (const tc of response.tool_calls) {
const fnName = tc.function.name;
let fnArgs: Record<string, unknown> = {};
try { fnArgs = JSON.parse(tc.function.arguments || '{}'); } catch { /* bad JSON */ }
toolCallCount++;
updateJob(job.id, {
progress: `Turn ${turn}: calling ${fnName}`,
toolCalls: [...(job.toolCalls || []), {
turn,
tool: fnName,
args: fnArgs,
timestamp: new Date().toISOString()
}]
});
let result: unknown;
try {
result = await executeTool(fnName, fnArgs, ctx);
} catch (err) {
result = { error: err instanceof Error ? err.message : String(err) };
}
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 = `Agent hit the ${MAX_TURNS}-turn safety limit. Tool calls made: ${toolCallCount}.`;
}
return { finalText, toolCallCount, turns: turn, model: llm.modelId };
}

View File

@@ -13,11 +13,12 @@
*/
import { execSync } from "child_process";
import { createLLM, toOAITools, LLMMessage } from "./llm";
import { callVibnChat } from "./llm/vibn-chat-model";
import { ChatMessage } from "./llm/gemini-chat";
import { AgentConfig } from "./agents";
import { executeTool, ToolContext } from "./tools";
import { resolvePrompt } from "./prompts/loader";
import { ingestSessionEvents } from "./vibn-events-ingest";
const MAX_TURNS = 60;
@@ -223,27 +224,17 @@ export async function runSessionAgent(
ctx: ToolContext,
opts: SessionRunOptions,
): Promise<void> {
const llm = createLLM(config.model, { temperature: 0.2 });
const oaiTools = toOAITools(config.tools);
const systemPrompt = resolvePrompt(config.promptId);
const emit = async (line: OutputLine) => {
console.log(`[session ${opts.sessionId}] ${line.type}: ${line.text}`);
await Promise.all([
patchSession(opts, { outputLine: line }),
ingestSessionEvents(opts.vibnApiUrl, opts.projectId, opts.sessionId, [
{
type: `output.${line.type}`,
payload: { text: line.text },
ts: line.ts,
},
]),
]);
await 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
@@ -257,166 +248,130 @@ When running commands, always cd into ${opts.appPath} first unless already there
Do NOT run git commit or git push — the platform handles committing after you finish.
`;
const history: LLMMessage[] = [{ role: "user", content: task }];
const history: ChatMessage[] = [{ role: "user", content: task }];
let turn = 0;
let finalText = "";
const trackedFiles = new Map<string, string>(); // path → status
let toolCallsSinceText = 0;
let roundsSinceText = 0;
const toolFingerprints: string[] = [];
let loopBreakReason: string | null = null;
function fingerprintToolCall(tc: any) {
if (tc.name === "shell_exec") {
const cmd = String(tc.args?.command ?? "").trim();
const verb = cmd.split("&&").map(s => s.trim()).find(s => !s.startsWith("cd "))?.split(/\s+/)[0] ?? "shell";
return `shell_exec:${verb}`;
}
if (tc.name === "fs_write" || tc.name === "fs_edit" || tc.name === "fs_read") {
return `${tc.name}:${tc.args?.path}`;
}
return `${tc.name}:${Object.values(tc.args ?? {})[0]}`;
}
while (turn < MAX_TURNS) {
// Check for stop signal between turns
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 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."
: "";
const messages: LLMMessage[] = [
{ role: "system", content: scopedPrompt },
...history,
];
let response: Awaited<ReturnType<typeof llm.chat>>;
let resp: any;
try {
response = await llm.chat(messages, oaiTools, 8192);
resp = await callVibnChat({
systemPrompt: scopedPrompt + extraSystem,
messages: history as any[],
tools: config.tools,
temperature: 0.2
});
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
await emit({ ts: now(), type: "error", text: `LLM error: ${msg}` });
await patchSession(opts, { status: "failed", error: msg });
return;
}
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;
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;
}
// Execute each tool call
for (const tc of response.tool_calls) {
if (opts.isStopped()) break;
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<string, number>();
for (const fp of window) counts.set(fp, (counts.get(fp) ?? 0) + 1);
const fnName = tc.function.name;
let fnArgs: Record<string, unknown> = {};
try {
fnArgs = JSON.parse(tc.function.arguments || "{}");
} catch {
/* bad JSON */
let maxRepeats = 0;
let repeatedCmd = "";
for (const [fp, n] of counts.entries()) {
if (n > maxRepeats) {
maxRepeats = n;
repeatedCmd = fp.split("|")[0];
}
}
// Human-readable step label
const stepLabel = buildStepLabel(fnName, fnArgs);
await emit({ ts: now(), type: "step", text: stepLabel });
let result: unknown;
if (maxRepeats >= 6) {
loopBreakReason = `Repeated ${repeatedCmd} ${maxRepeats}× in last 10 calls`;
break;
}
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 executeTool(fnName, fnArgs, ctx);
result = await 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 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}`,
});
}
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: 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)})`;
await patchSession(opts, { status: "failed", error: "Max turns reached" });
}
}

View File

@@ -1,9 +0,0 @@
import { registerAgent, pick } from './registry';
registerAgent({
name: 'Atlas',
description: 'PRD agent — guides users through structured product discovery and produces a comprehensive requirements document',
model: 'A', // Gemini Flash — fast, conversational, cost-effective for dialogue
promptId: 'atlas',
tools: pick(['web_search', 'finalize_prd'])
});

View File

@@ -1,15 +1,10 @@
import { registerAgent, pick } from './registry';
import { registerAgent } from './registry';
import { ALL_TOOLS } from '../tools';
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: 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: ALL_TOOLS
});

View File

@@ -1,12 +0,0 @@
import { registerAgent, pick } from './registry';
registerAgent({
name: 'ImportAnalyzer',
description: 'Reads an imported codebase end-to-end and produces CODEBASE_MAP.md and MIGRATION_PLAN.md',
model: 'B',
promptId: 'import-analyzer',
tools: pick([
'read_file', 'write_file', 'list_directory', 'find_files', 'search_code',
'git_commit_and_push',
])
});

View File

@@ -1,18 +1,8 @@
// Import prompt templates first — side effects register them before agents reference promptIds
import '../prompts/orchestrator';
// Import prompt templates first
import '../prompts/coder';
import '../prompts/pm';
import '../prompts/marketing';
import '../prompts/atlas';
import '../prompts/import-analyzer';
// Import agent files — side effects register each agent into the registry
import './orchestrator';
// Import agent files
import './coder';
import './pm';
import './marketing';
import './atlas';
import './import-analyzer';
// Re-export public API
export { AgentConfig, AGENTS, getAgent, allAgents, pick } from './registry';

View File

@@ -1,13 +0,0 @@
import { registerAgent, pick } from './registry';
registerAgent({
name: 'Marketing',
description: 'Marketing specialist — copy, blog posts, release notes, landing page content',
model: 'A',
promptId: 'marketing',
tools: pick([
'read_file', 'write_file', 'replace_in_file', 'list_directory', 'find_files', 'search_code',
'git_commit_and_push',
'get_skill'
])
});

View File

@@ -1,16 +0,0 @@
import { registerAgent, pick } from './registry';
registerAgent({
name: 'Orchestrator',
description: 'Master coordinator — breaks down goals and delegates to specialist agents',
model: 'B',
promptId: 'orchestrator',
tools: pick([
'gitea_create_issue', 'gitea_list_issues', 'gitea_close_issue',
'spawn_agent', 'get_job_status',
'coolify_list_projects', 'coolify_list_applications', 'coolify_deploy', 'coolify_get_logs',
'list_repos', 'list_all_issues', 'list_all_apps', 'get_app_status',
'read_repo_file', 'deploy_app', 'save_memory',
'list_skills', 'get_skill'
])
});

View File

@@ -1,14 +0,0 @@
import { registerAgent, pick } from './registry';
registerAgent({
name: 'PM',
description: 'Product manager — docs, issue management, project health reports',
model: 'A',
promptId: 'pm',
tools: pick([
'gitea_create_issue', 'gitea_list_issues', 'gitea_close_issue',
'read_file', 'write_file', 'replace_in_file', 'list_directory', 'find_files', 'search_code',
'git_commit_and_push',
'get_skill'
])
});

View File

@@ -1,175 +0,0 @@
import { createLLM, toOAITools, LLMMessage } from './llm';
import { ALL_TOOLS, executeTool, ToolContext } from './tools';
import { resolvePrompt } from './prompts/loader';
const MAX_TURNS = 10; // Atlas is conversational — low turn count, no deep tool loops
// ---------------------------------------------------------------------------
// Session store
// ---------------------------------------------------------------------------
interface AtlasSession {
id: string;
history: LLMMessage[];
prdContent: string | null;
createdAt: string;
lastActiveAt: string;
}
const sessions = new Map<string, AtlasSession>();
function getOrCreateSession(sessionId: string): AtlasSession {
if (!sessions.has(sessionId)) {
sessions.set(sessionId, {
id: sessionId,
history: [],
prdContent: null,
createdAt: new Date().toISOString(),
lastActiveAt: new Date().toISOString()
});
}
const session = sessions.get(sessionId)!;
session.lastActiveAt = new Date().toISOString();
return session;
}
export function clearAtlasSession(sessionId: string): void {
sessions.delete(sessionId);
}
export function listAtlasSessions() {
return Array.from(sessions.values()).map(s => ({
id: s.id,
messages: s.history.length,
prdReady: s.prdContent !== null,
createdAt: s.createdAt,
lastActiveAt: s.lastActiveAt
}));
}
// ---------------------------------------------------------------------------
// Atlas chat result
// ---------------------------------------------------------------------------
export interface AtlasChatResult {
reply: string;
sessionId: string;
history: LLMMessage[];
/** Set when Atlas has called finalize_prd — contains the full PRD markdown */
prdContent: string | null;
model: string;
}
// ---------------------------------------------------------------------------
// Main chat handler
// ---------------------------------------------------------------------------
const ATLAS_TOOLS = ALL_TOOLS.filter(t => ['finalize_prd', 'web_search'].includes(t.name));
export async function atlasChat(
sessionId: string,
userMessage: string,
ctx: ToolContext,
opts?: {
preloadedHistory?: LLMMessage[];
/** When true, the user message is an internal init trigger and should not be stored in history */
isInit?: boolean;
}
): Promise<AtlasChatResult> {
const llm = createLLM(process.env.ATLAS_MODEL ?? 'A', { temperature: 0.5 });
const session = getOrCreateSession(sessionId);
// Seed from DB history if this is a fresh in-memory session
if (opts?.preloadedHistory && opts.preloadedHistory.length > 0 && session.history.length === 0) {
session.history = [...opts.preloadedHistory];
}
const oaiTools = toOAITools(ATLAS_TOOLS);
const systemPrompt = resolvePrompt('atlas');
// Always push the user message so Gemini gets a valid conversation (requires at least one user turn).
// For init triggers, we mark it so we can strip it from the returned history — it's an internal
// prompt, not a real user message, and shouldn't appear in the conversation UI or DB.
const INIT_MARKER = '__atlas_init_marker__';
session.history.push({
role: 'user',
content: opts?.isInit ? INIT_MARKER + userMessage : userMessage
});
const buildMessages = (): LLMMessage[] => [
{ role: 'system', content: systemPrompt },
...session.history.slice(-60).map(m =>
// Strip the init marker before sending to the LLM
m.role === 'user' && typeof m.content === 'string' && m.content.startsWith(INIT_MARKER)
? { ...m, content: m.content.slice(INIT_MARKER.length) }
: m
)
];
let turn = 0;
let finalReply = '';
let prdContent: string | null = session.prdContent;
while (turn < MAX_TURNS) {
turn++;
const response = await llm.chat(buildMessages(), oaiTools, 4096);
const hasContent = response.content !== null && response.content !== '';
const hasToolCalls = response.tool_calls.length > 0;
if (hasContent || hasToolCalls) {
session.history.push({
role: 'assistant',
content: response.content,
tool_calls: hasToolCalls ? response.tool_calls : undefined
});
}
if (!hasToolCalls) {
finalReply = response.content ?? '';
break;
}
// Execute tool calls (only finalize_prd for Atlas)
for (const tc of response.tool_calls) {
let fnArgs: Record<string, unknown> = {};
try { fnArgs = JSON.parse(tc.function.arguments || '{}'); } catch { /* bad JSON */ }
let result: unknown;
try {
result = await executeTool(tc.function.name, fnArgs, ctx);
} catch (err) {
result = { error: err instanceof Error ? err.message : String(err) };
}
// Check if PRD was just saved
const stored = undefined;
if (stored && !prdContent) {
prdContent = stored;
session.prdContent = stored;
}
session.history.push({
role: 'tool',
tool_call_id: tc.id,
name: tc.function.name,
content: typeof result === 'string' ? result : JSON.stringify(result)
});
}
}
return {
reply: finalReply,
sessionId,
history: session.history
// Drop the internal init user turn — it's not a real user message
.filter(m => !(m.role === 'user' && typeof m.content === 'string' && m.content.startsWith(INIT_MARKER)))
.filter(m => m.role !== 'assistant' || m.content || m.tool_calls?.length)
.slice(-60),
prdContent,
model: llm.modelId
};
}

View File

@@ -1,69 +0,0 @@
import { v4 as uuidv4 } from 'uuid';
// ---------------------------------------------------------------------------
// Job types
// ---------------------------------------------------------------------------
export type JobStatus = 'queued' | 'running' | 'completed' | 'failed';
export interface ToolCallRecord {
turn: number;
tool: string;
args: unknown;
timestamp: string;
}
export interface Job {
id: string;
agent: string;
task: string;
repo?: string;
status: JobStatus;
progress: string;
toolCalls: ToolCallRecord[];
result?: string;
error?: string;
createdAt: string;
updatedAt: string;
}
// ---------------------------------------------------------------------------
// In-memory store (swap for Redis/DB if scaling horizontally)
// ---------------------------------------------------------------------------
const store = new Map<string, Job>();
export function createJob(agent: string, task: string, repo?: string): Job {
const job: Job = {
id: uuidv4(),
agent,
task,
repo,
status: 'queued',
progress: 'Job queued',
toolCalls: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
};
store.set(job.id, job);
return job;
}
export function getJob(id: string): Job | undefined {
return store.get(id);
}
export function updateJob(id: string, updates: Partial<Job>): Job | undefined {
const job = store.get(id);
if (!job) return undefined;
const updated = { ...job, ...updates, id, updatedAt: new Date().toISOString() };
store.set(id, updated);
return updated;
}
export function listJobs(limit = 50): Job[] {
const all = Array.from(store.values());
return all
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
.slice(0, limit);
}

View File

@@ -1,562 +0,0 @@
import { GoogleAuth } from "google-auth-library";
import { GoogleGenAI } from "@google/genai";
import AnthropicVertex from "@anthropic-ai/vertex-sdk";
import { v4 as uuidv4 } from "uuid";
// =============================================================================
// Unified LLM client — OpenAI-compatible message format throughout
//
// Three backends:
// VertexOpenAIClient — GLM-5 and other Vertex MaaS models (openai-compat endpoint)
// GeminiClient — Gemini Flash/Pro via @google/genai SDK (API key)
// AnthropicVertexClient — Claude models via Anthropic Messages API on Vertex (us-east5)
//
// Model tier defaults (overridable via TIER_A/B/C_MODEL env vars):
// Tier A: gemini-3.1-pro-preview — routing, summaries (API key, high quota)
// Tier B: claude-sonnet-4-6 — coding, feature work (Anthropic Vertex, us-east5)
// Tier C: claude-sonnet-4-6 — complex decisions
// =============================================================================
// ---------------------------------------------------------------------------
// Shared message types (OpenAI format — used everywhere internally)
// ---------------------------------------------------------------------------
export interface LLMMessage {
role: "system" | "user" | "assistant" | "tool";
content: string | null;
tool_calls?: LLMToolCall[];
tool_call_id?: string; // set on role=tool messages
name?: string; // function name on role=tool messages
}
export interface LLMToolCall {
id: string;
type: "function";
function: {
name: string;
arguments: string; // JSON-encoded string
};
}
export interface LLMTool {
type: "function";
function: {
name: string;
description: string;
parameters: Record<string, unknown>;
};
}
export interface LLMResponse {
content: string | null;
reasoning: string | null; // GLM-5 chain-of-thought
tool_calls: LLMToolCall[];
finish_reason: string;
usage?: {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
};
}
/**
* Strips DeepSeek-specific XML tags like <tool_calls> and <think> from content
* so it doesn't leak into the model's history and cause subsequent hallucinations.
*/
function stripModelMarkup(text: string | null | undefined): string | null {
if (!text) return null;
return (
text
.replace(/<tool_calls>[\s\S]*?<\/tool_calls>/g, "")
.replace(/<think>[\s\S]*?<\/think>/g, "")
.trim() || null
);
}
export interface LLMClient {
modelId: string;
chat(
messages: LLMMessage[],
tools?: LLMTool[],
maxTokens?: number,
): Promise<LLMResponse>;
}
// ---------------------------------------------------------------------------
// Vertex AI OpenAI-compatible client
// Used for: zai-org/glm-5-maas, anthropic/claude-sonnet-4-6, etc.
// ---------------------------------------------------------------------------
let _cachedToken = "";
let _tokenExpiry = 0;
// Build GoogleAuth with explicit service account credentials when available.
// GCP_SA_KEY_BASE64: base64-encoded service account JSON key — safe to pass as
// an env var since it contains no newlines or special shell characters.
// Falls back to the GCP metadata server (works on VMs with correct scopes).
function buildGoogleAuth(): GoogleAuth {
const b64Key = process.env.GCP_SA_KEY_BASE64;
if (b64Key) {
try {
const jsonStr = Buffer.from(b64Key, "base64").toString("utf8");
const credentials = JSON.parse(jsonStr);
return new GoogleAuth({
credentials,
scopes: ["https://www.googleapis.com/auth/cloud-platform"],
});
} catch {
console.warn(
"[llm] GCP_SA_KEY_BASE64 is set but failed to decode/parse — falling back to metadata server",
);
}
}
return new GoogleAuth({
scopes: ["https://www.googleapis.com/auth/cloud-platform"],
});
}
const _googleAuth = buildGoogleAuth();
async function getVertexToken(): Promise<string> {
const now = Date.now();
if (_cachedToken && now < _tokenExpiry) return _cachedToken;
const client = await _googleAuth.getClient();
const tokenResponse = await client.getAccessToken();
_cachedToken = tokenResponse.token!;
_tokenExpiry = now + 55 * 60 * 1000; // tokens last 1hr, refresh at 55min
return _cachedToken;
}
export class VertexOpenAIClient implements LLMClient {
modelId: string;
private projectId: string;
private region: string;
private temperature: number;
constructor(
modelId: string,
opts?: { projectId?: string; region?: string; temperature?: number },
) {
this.modelId = modelId;
this.projectId =
opts?.projectId ?? process.env.GCP_PROJECT_ID ?? "master-ai-484822";
this.region = opts?.region ?? "global";
this.temperature = opts?.temperature ?? 0.3;
}
async chat(
messages: LLMMessage[],
tools?: LLMTool[],
maxTokens = 4096,
): Promise<LLMResponse> {
const base =
this.region === "global"
? "https://aiplatform.googleapis.com"
: `https://${this.region}-aiplatform.googleapis.com`;
const url = `${base}/v1/projects/${this.projectId}/locations/${this.region}/endpoints/openapi/chat/completions`;
const body: Record<string, unknown> = {
model: this.modelId,
messages,
max_tokens: maxTokens,
temperature: this.temperature,
stream: false,
};
if (tools && tools.length > 0) {
body.tools = tools;
body.tool_choice = "auto";
}
// Retry with exponential backoff on 429 / 503 (rate limit / overload)
const MAX_RETRIES = 4;
const RETRY_STATUSES = new Set([429, 503]);
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
const token = await getVertexToken();
const res = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (res.ok) {
const data = (await res.json()) as any;
const choice = data.choices?.[0];
const message = choice?.message ?? {};
return {
content: stripModelMarkup(message.content),
reasoning: stripModelMarkup(message.reasoning_content),
tool_calls: message.tool_calls ?? [],
finish_reason: choice?.finish_reason ?? "stop",
usage: data.usage,
};
}
const errText = await res.text();
// Force token refresh on 401
if (res.status === 401) _tokenExpiry = 0;
if (RETRY_STATUSES.has(res.status) && attempt < MAX_RETRIES) {
// Check for Retry-After header, otherwise use exponential backoff
const retryAfter = res.headers.get("retry-after");
const waitMs = retryAfter
? Math.min(parseInt(retryAfter, 10) * 1000, 60_000)
: Math.min(2 ** attempt * 2000 + Math.random() * 500, 30_000);
console.warn(
`[llm] Vertex ${res.status} on attempt ${attempt + 1}/${MAX_RETRIES + 1} — retrying in ${Math.round(waitMs / 1000)}s`,
);
await new Promise((r) => setTimeout(r, waitMs));
continue;
}
throw new Error(`Vertex API ${res.status}: ${errText.slice(0, 400)}`);
}
// TypeScript requires an explicit throw after the loop (unreachable in practice)
throw new Error("Vertex API: exceeded max retries");
}
}
// ---------------------------------------------------------------------------
// Gemini client via @google/genai SDK
// Used for: Tier A (fast/cheap routing, summaries, log parsing)
// Converts to/from OpenAI message format internally.
// ---------------------------------------------------------------------------
export class GeminiClient implements LLMClient {
modelId: string;
private temperature: number;
constructor(modelId = "gemini-3.1-pro-preview", opts?: { temperature?: number }) {
this.modelId = modelId;
this.temperature = opts?.temperature ?? 0.2;
}
async chat(
messages: LLMMessage[],
tools?: LLMTool[],
maxTokens = 8192,
): Promise<LLMResponse> {
const apiKey = process.env.GOOGLE_API_KEY;
if (!apiKey) throw new Error("GOOGLE_API_KEY not set");
const genai = new GoogleGenAI({ apiKey });
const systemMsg = messages.find((m) => m.role === "system");
const nonSystem = messages.filter((m) => m.role !== "system");
const functionDeclarations = (tools ?? []).map((t) => ({
name: t.function.name,
description: t.function.description,
parameters: t.function.parameters as any,
}));
const response = await genai.models.generateContent({
model: this.modelId,
contents: toGeminiContents(nonSystem),
config: {
systemInstruction: systemMsg?.content ?? undefined,
tools:
functionDeclarations.length > 0
? [{ functionDeclarations }]
: undefined,
temperature: this.temperature,
maxOutputTokens: maxTokens,
},
});
const candidate = response.candidates?.[0];
if (!candidate) throw new Error("No response from Gemini");
const parts = candidate.content?.parts ?? [];
const textContent =
parts
.filter((p) => p.text)
.map((p) => p.text)
.join("") || null;
const fnCalls = parts.filter((p) => p.functionCall);
const tool_calls: LLMToolCall[] = fnCalls.map((p) => ({
id: `call_${uuidv4().replace(/-/g, "").slice(0, 12)}`,
type: "function" as const,
function: {
name: p.functionCall!.name ?? "",
arguments: JSON.stringify(p.functionCall!.args ?? {}),
},
}));
return {
content: stripModelMarkup(textContent),
reasoning: null,
tool_calls,
finish_reason: fnCalls.length > 0 ? "tool_calls" : "stop",
};
}
}
/** Convert OpenAI message format → Gemini Content[] format */
function toGeminiContents(messages: LLMMessage[]): any[] {
const contents: any[] = [];
for (const msg of messages) {
if (msg.role === "assistant") {
const parts: any[] = [];
if (msg.content) parts.push({ text: msg.content });
for (const tc of msg.tool_calls ?? []) {
parts.push({
functionCall: {
name: tc.function.name,
args: JSON.parse(tc.function.arguments || "{}"),
},
});
}
contents.push({ role: "model", parts });
} else if (msg.role === "tool") {
// Parse content back — could be JSON or plain text
let resultValue: unknown = msg.content;
try {
resultValue = JSON.parse(msg.content ?? "null");
} catch {
/* keep as string */
}
contents.push({
role: "user",
parts: [
{
functionResponse: {
name: msg.name ?? "tool",
response: { result: resultValue },
},
},
],
});
} else {
contents.push({ role: "user", parts: [{ text: msg.content ?? "" }] });
}
}
return contents;
}
// ---------------------------------------------------------------------------
// Anthropic Vertex client
// Used for: claude-* models via Vertex AI (proper Anthropic Messages API)
// Handles tool_calls by converting to/from Anthropic's tool_use blocks.
// ---------------------------------------------------------------------------
export class AnthropicVertexClient implements LLMClient {
modelId: string;
private projectId: string;
private region: string;
constructor(modelId: string, opts?: { projectId?: string; region?: string }) {
// Strip the "anthropic/" prefix if present — the SDK uses bare model names
this.modelId = modelId.startsWith("anthropic/")
? modelId.slice(10)
: modelId;
this.projectId =
opts?.projectId ?? process.env.GCP_PROJECT_ID ?? "master-ai-484822";
this.region = opts?.region ?? process.env.CLAUDE_REGION ?? "us-east5";
}
private buildClient(): AnthropicVertex {
const b64Key = process.env.GCP_SA_KEY_BASE64;
if (b64Key) {
try {
const jsonStr = Buffer.from(b64Key, "base64").toString("utf8");
const credentials = JSON.parse(jsonStr);
return new AnthropicVertex({
projectId: this.projectId,
region: this.region,
googleAuth: new GoogleAuth({
credentials,
scopes: ["https://www.googleapis.com/auth/cloud-platform"],
}) as any,
});
} catch {
console.warn(
"[llm] AnthropicVertex: SA key decode failed, falling back to metadata server",
);
}
}
return new AnthropicVertex({
projectId: this.projectId,
region: this.region,
});
}
async chat(
messages: LLMMessage[],
tools?: LLMTool[],
maxTokens = 8192,
): Promise<LLMResponse> {
const client = this.buildClient();
const system =
messages.find((m) => m.role === "system")?.content ?? undefined;
const nonSystem = messages.filter((m) => m.role !== "system");
// Convert OpenAI message format → Anthropic format
const anthropicMessages: any[] = nonSystem.map((m) => {
if (m.role === "assistant") {
const parts: any[] = [];
if (m.content) parts.push({ type: "text", text: m.content });
for (const tc of m.tool_calls ?? []) {
parts.push({
type: "tool_use",
id: tc.id,
name: tc.function.name,
input: JSON.parse(tc.function.arguments || "{}"),
});
}
return {
role: "assistant",
content:
parts.length === 1 && parts[0].type === "text"
? parts[0].text
: parts,
};
}
if (m.role === "tool") {
return {
role: "user",
content: [
{
type: "tool_result",
tool_use_id: m.tool_call_id,
content: m.content ?? "",
},
],
};
}
return { role: "user", content: m.content ?? "" };
});
const anthropicTools = (tools ?? []).map((t) => ({
name: t.function.name,
description: t.function.description,
input_schema: t.function.parameters,
}));
const MAX_RETRIES = 4;
const RETRY_STATUSES = new Set([429, 503]);
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
try {
const response = await (client.messages.create as Function)({
model: this.modelId,
max_tokens: maxTokens,
system: system ?? undefined,
messages: anthropicMessages,
tools: anthropicTools.length > 0 ? anthropicTools : undefined,
});
const textContent =
response.content
.filter((b: any) => b.type === "text")
.map((b: any) => b.text)
.join("") || null;
const tool_calls: LLMToolCall[] = response.content
.filter((b: any) => b.type === "tool_use")
.map((b: any) => ({
id: b.id,
type: "function" as const,
function: {
name: b.name,
arguments: JSON.stringify(b.input ?? {}),
},
}));
return {
content: stripModelMarkup(textContent),
reasoning: null,
tool_calls,
finish_reason:
response.stop_reason === "tool_use" ? "tool_calls" : "stop",
usage: response.usage
? {
prompt_tokens: response.usage.input_tokens,
completion_tokens: response.usage.output_tokens,
total_tokens:
response.usage.input_tokens + response.usage.output_tokens,
}
: undefined,
};
} catch (err: any) {
const status = err?.status ?? err?.statusCode ?? 0;
if (RETRY_STATUSES.has(status) && attempt < MAX_RETRIES) {
const waitMs = Math.min(
2 ** attempt * 2000 + Math.random() * 500,
30_000,
);
console.warn(
`[llm] Anthropic Vertex ${status} on attempt ${attempt + 1}/${MAX_RETRIES + 1} — retrying in ${Math.round(waitMs / 1000)}s`,
);
await new Promise((r) => setTimeout(r, waitMs));
continue;
}
throw new Error(
`Anthropic Vertex error: ${err?.message ?? String(err)}`,
);
}
}
throw new Error("Anthropic Vertex: exceeded max retries");
}
}
// ---------------------------------------------------------------------------
// Factory — createLLM(modelId | tier)
// ---------------------------------------------------------------------------
export type ModelTier = "A" | "B" | "C";
const TIER_MODELS: Record<ModelTier, string> = {
A: process.env.TIER_A_MODEL ?? "gemini-3.1-pro-preview",
B: process.env.TIER_B_MODEL ?? "claude-sonnet-4-6",
C: process.env.TIER_C_MODEL ?? "claude-sonnet-4-6",
};
export function createLLM(
modelOrTier: string | ModelTier,
opts?: { temperature?: number },
): LLMClient {
const modelId =
modelOrTier === "A" || modelOrTier === "B" || modelOrTier === "C"
? TIER_MODELS[modelOrTier]
: modelOrTier;
if (modelId.startsWith("gemini-")) {
return new GeminiClient(modelId, opts);
}
if (modelId.startsWith("anthropic/") || modelId.startsWith("claude-")) {
return new AnthropicVertexClient(modelId);
}
return new VertexOpenAIClient(modelId, { temperature: opts?.temperature });
}
// ---------------------------------------------------------------------------
// Helper — convert our ToolDefinition[] → LLMTool[] (OpenAI format)
// ---------------------------------------------------------------------------
export function toOAITools(
tools: Array<{
name: string;
description: string;
parameters: Record<string, unknown>;
}>,
): LLMTool[] {
return tools.map((t) => ({
type: "function",
function: {
name: t.name,
description: t.description,
parameters: t.parameters,
},
}));
}

View File

@@ -0,0 +1,212 @@
import { GoogleGenAI } from '@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 GoogleGenAI({ apiKey: GEMINI_API_KEY });
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;
}
function toGeminiContents(messages: ChatMessage[]) {
const contents: any[] = [];
for (const msg of messages) {
if (msg.role === "user") {
contents.push({ role: "user", parts: [{ text: msg.content }] });
} else if (msg.role === "assistant") {
const parts: any[] = [];
if (msg.content) parts.push({ text: msg.content });
if (msg.toolCalls?.length) {
for (const tc of msg.toolCalls) {
const part: any = {
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: ToolDefinition[]) {
if (!tools.length) return undefined;
return [
{
functionDeclarations: tools.map((t) => ({
name: t.name,
description: t.description,
parameters: t.parameters as any,
})),
},
];
}
export async function callGeminiChat(opts: {
systemPrompt: string;
messages: ChatMessage[];
tools?: ToolDefinition[];
temperature?: number;
includeThoughts?: boolean;
}): Promise<{
text: string;
thoughts: string;
toolCalls: ToolCall[];
finishReason?: string;
error?: string;
}> {
try {
const config: any = {
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: ToolCall[] = [];
const parts = response.candidates?.[0]?.content?.parts ?? [];
for (const part of parts) {
if (part.text) {
if ((part as any).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 as Record<string, unknown> ?? {},
thoughtSignature: (part as any).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)}`,
};
}
}
export async function* streamGeminiChat(opts: {
systemPrompt: string;
messages: ChatMessage[];
tools?: ToolDefinition[];
temperature?: number;
}): AsyncGenerator<ChatChunk> {
try {
const config: any = {
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 as any).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,377 @@
/**
* 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";
const DEFAULT_CHAT_URL = "https://api.deepseek.com/chat/completions";
function resolveApiKey(): string {
return (
process.env.DEEPSEEK_API_KEY?.trim() ||
process.env.VIBN_OPENAI_COMPATIBLE_API_KEY?.trim() ||
""
);
}
function resolveChatUrl(): string {
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(): string {
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: unknown): unknown {
if (node === null || typeof node !== "object" || Array.isArray(node))
return node;
const n = node as Record<string, unknown>;
const out: Record<string, unknown> = {};
for (const [key, val] of Object.entries(n)) {
if (key === "type" && typeof val === "string") {
const map: Record<string, string> = {
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 as object).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: ToolDefinition[] | undefined,
): object[] | undefined {
if (!tools?.length) return undefined;
return tools.map((t) => ({
type: "function",
function: {
name: t.name,
description: t.description,
parameters: geminiStyleToJsonSchema(t.parameters) as Record<
string,
unknown
>,
},
}));
}
/**
* 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: ChatMessage[],
): ChatMessage[] {
const result: ChatMessage[] = [];
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<string, ChatMessage>();
const bufferedUsers: ChatMessage[] = [];
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: string,
messages: ChatMessage[],
): object[] {
const normalized = reorderMessagesForOpenAiToolPairs(messages);
const out: object[] = [{ 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: Record<string, unknown> = {
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: Record<string, unknown> | undefined): {
text: string;
thoughts: string;
toolCalls: ToolCall[];
} {
const rawText = typeof message?.content === "string" ? message.content : "";
const thoughts =
typeof message?.reasoning_content === "string"
? message.reasoning_content
: typeof (message as { reasoning?: string })?.reasoning === "string"
? (message as { reasoning: string }).reasoning
: "";
const stripTags = (s: string) =>
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: ToolCall[] = [];
const rawCalls = message?.tool_calls;
if (Array.isArray(rawCalls)) {
for (const c of rawCalls) {
const call = c as Record<string, unknown>;
if (call.type !== "function") continue;
const fn = call.function as Record<string, unknown> | undefined;
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: Record<string, unknown> = {};
const argStr = typeof fn?.arguments === "string" ? fn.arguments : "{}";
try {
args = JSON.parse(argStr || "{}") as Record<string, unknown>;
} catch {
args = {};
}
if (name) toolCalls.push({ id, name, args });
}
}
return { text, thoughts, toolCalls };
}
/**
* Non-streaming chat + tool calls — mirrors {@link callGeminiChat} return shape.
*/
export async 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;
}> {
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: Record<string, unknown> = {
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: any) => ({
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: any) => 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: any) => m.role),
}),
);
// ───────────────────────────────────────────────────────────────────────
let res: Response;
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(() => ({}))) as Record<string, unknown>;
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 as Record<string, unknown> | undefined;
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 as Record<string, unknown>[] | undefined)?.[0];
const message = choice?.message as Record<string, unknown> | undefined;
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,33 @@
/**
* 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';
import { callGeminiChat } from './gemini-chat';
import { callOpenAiCompatibleChat } from './openai-compatible-chat';
export type VibnChatCallOpts = {
systemPrompt: string;
messages: ChatMessage[];
tools?: ToolDefinition[];
temperature?: number;
includeThoughts?: boolean;
};
export async function callVibnChat(opts: VibnChatCallOpts) {
const p = (process.env.VIBN_CHAT_PROVIDER || 'gemini').toLowerCase().trim();
if (p === 'deepseek' || p === 'openai_compatible') {
return callOpenAiCompatibleChat(opts);
}
return callGeminiChat(opts);
}

View File

@@ -1,189 +0,0 @@
import { createLLM, toOAITools, LLMMessage } from './llm';
import { ALL_TOOLS, executeTool, ToolContext, MemoryUpdate } from './tools';
import { resolvePrompt } from './prompts/loader';
const MAX_TURNS = 20;
// ---------------------------------------------------------------------------
// Session store — one conversation history per session_id
// ---------------------------------------------------------------------------
interface Session {
id: string;
history: LLMMessage[]; // OpenAI message format
createdAt: string;
lastActiveAt: string;
}
const sessions = new Map<string, Session>();
function getOrCreateSession(sessionId: string): Session {
if (!sessions.has(sessionId)) {
sessions.set(sessionId, {
id: sessionId,
history: [],
createdAt: new Date().toISOString(),
lastActiveAt: new Date().toISOString()
});
}
const session = sessions.get(sessionId)!;
session.lastActiveAt = new Date().toISOString();
return session;
}
export function listSessions() {
return Array.from(sessions.values()).map(s => ({
id: s.id,
messages: s.history.length,
createdAt: s.createdAt,
lastActiveAt: s.lastActiveAt
}));
}
export function clearSession(sessionId: string) {
sessions.delete(sessionId);
}
// Prompt text lives in src/prompts/orchestrator.ts — imported via agents/index.ts
// which is loaded before orchestratorChat() is first called.
// ---------------------------------------------------------------------------
// Chat types
// ---------------------------------------------------------------------------
export interface ChatResult {
reply: string;
reasoning: string | null;
sessionId: string;
turns: number;
toolCalls: string[];
model: string;
/** Updated conversation history — caller should persist this */
history: LLMMessage[];
/** Knowledge items the AI chose to save this turn */
memoryUpdates: MemoryUpdate[];
}
// ---------------------------------------------------------------------------
// Main orchestrator chat — uses GLM-5 (Tier B) by default
// ---------------------------------------------------------------------------
export async function orchestratorChat(
sessionId: string,
userMessage: string,
ctx: ToolContext,
opts?: {
/** Pre-load history from DB — replaces in-memory session history */
preloadedHistory?: LLMMessage[];
/** Knowledge items to inject as context at start of conversation */
knowledgeContext?: string;
}
): Promise<ChatResult> {
const modelId = process.env.ORCHESTRATOR_MODEL ?? 'B'; // Tier B = GLM-5
const llm = createLLM(modelId, { temperature: 0.3 });
const session = getOrCreateSession(sessionId);
// Seed session from DB history if provided and session is fresh
if (opts?.preloadedHistory && opts.preloadedHistory.length > 0 && session.history.length === 0) {
session.history = [...opts.preloadedHistory];
}
const oaiTools = toOAITools(ALL_TOOLS);
// Append user message
session.history.push({ role: 'user', content: userMessage });
let turn = 0;
let finalReply = '';
let finalReasoning: string | null = null;
const toolCallNames: string[] = [];
// Resolve system prompt from template — {{knowledge}} injects project/COO context
const systemContent = resolvePrompt('orchestrator', {
knowledge: opts?.knowledgeContext ?? ''
});
// Build messages with system prompt prepended; keep last 40 for cost control
const buildMessages = (): LLMMessage[] => [
{ role: 'system', content: systemContent },
...session.history.slice(-40)
];
while (turn < MAX_TURNS) {
turn++;
const response = await llm.chat(buildMessages(), oaiTools, 4096);
// If GLM-5 is still reasoning (content null, finish_reason length) give it more tokens
if (response.content === null && response.tool_calls.length === 0 && response.finish_reason === 'length') {
// Retry with more tokens — model hit max_tokens during reasoning
const retry = await llm.chat(buildMessages(), oaiTools, 8192);
Object.assign(response, retry);
}
// Record reasoning for the final turn (informational, not stored in history)
if (response.reasoning) finalReasoning = response.reasoning;
// Only push assistant message if it has actual content or tool calls;
// skip empty turns that result from mid-reasoning token exhaustion.
const hasContent = response.content !== null && response.content !== '';
const hasToolCalls = response.tool_calls.length > 0;
if (hasContent || hasToolCalls) {
const assistantMsg: LLMMessage = {
role: 'assistant',
content: response.content,
tool_calls: hasToolCalls ? response.tool_calls : undefined
};
session.history.push(assistantMsg);
}
// No tool calls — we have the final answer
if (!hasToolCalls) {
finalReply = response.content ?? '';
break;
}
// Execute each tool call and collect results
for (const tc of response.tool_calls) {
const fnName = tc.function.name;
let fnArgs: Record<string, unknown> = {};
try { fnArgs = JSON.parse(tc.function.arguments || '{}'); } catch { /* bad JSON */ }
toolCallNames.push(fnName);
let result: unknown;
try {
result = await executeTool(fnName, fnArgs, ctx);
} catch (err) {
result = { error: err instanceof Error ? err.message : String(err) };
}
// Add tool result to history
session.history.push({
role: 'tool',
tool_call_id: tc.id,
name: fnName,
content: typeof result === 'string' ? result : JSON.stringify(result)
});
}
}
if (turn >= MAX_TURNS && !finalReply) {
finalReply = 'Hit the turn limit. Try a more specific request.';
}
return {
reply: finalReply,
reasoning: finalReasoning,
sessionId,
turns: turn,
toolCalls: toolCallNames,
model: llm.modelId,
history: session.history
.filter(m => m.role !== 'assistant' || m.content || m.tool_calls?.length)
.slice(-40),
memoryUpdates: ctx.memoryUpdates
};
}

View File

@@ -1,220 +0,0 @@
import { registerPrompt } from './loader';
registerPrompt('atlas', `
You are Vibn, a senior product strategist and requirements architect. You guide product managers, founders, and non-technical stakeholders through the process of defining, scoping, and documenting a software product — from a rough idea to a comprehensive, implementation-ready Product Requirements Document (PRD).
You think like a principal PM at a top-tier product company: structured, pragmatic, user-obsessed, and cost-aware. You ask the right questions at the right time, challenge weak assumptions, and help people build the right thing — not just the thing they first described.
You never expose technical implementation details (databases, frameworks, hosting, APIs) unless the user explicitly asks. Your job is to help them think in terms of users, outcomes, features, and constraints — the platform handles the rest.
## Core Behavior Rules
1. **Lead with curiosity, not assumptions.** Never generate a full PRD from a single sentence. Conduct a structured discovery conversation first.
2. **One phase at a time.** Move through the discovery phases sequentially. Don't skip ahead unless the user provides enough detail to justify it.
3. **Summarize and keep moving.** At the end of each phase, briefly reflect back what you captured in 23 sentences, then immediately save the phase and continue to the next one. Do NOT ask "Does that sound right?" or wait for the user to confirm before advancing. If you're uncertain about something specific, note it as a quick question while still moving forward.
4. **Challenge gently.** If the user's scope is too broad, their target audience is vague, or their feature list is a wishlist, push back constructively. Say things like: "That's a great long-term vision. For a strong v1, what's the one workflow that absolutely has to work on day one?"
5. **Stay in product language.** Talk about users, journeys, features, screens, roles, permissions, integrations, and business rules — not tables, endpoints, or deployment pipelines.
6. **Respect what you don't know.** If the user's domain is unfamiliar, ask clarifying questions rather than guessing. Incorrect assumptions in a PRD are expensive.
7. **Be opinionated when it helps.** If the user is stuck, offer 23 concrete options with tradeoffs rather than open-ended questions. Guide, don't interrogate.
## Discovery Conversation Flow
Guide the user through these phases. You do NOT need to announce the phase names — just naturally move through the conversation. Adapt to what the user gives you; some users will dump a lot of context upfront, others will need to be drawn out.
### Phase 1 — The Big Picture
Goal: Understand what they're building and why.
- What is this product/tool/app in one sentence?
- Who is it for? (Be specific — not "everyone" or "businesses")
- What problem does it solve? What are people doing today instead?
- What does success look like in 6 months?
- Is this brand-new, a feature within something existing, or a replacement?
- Are there competitors? What's different about this one?
- Is there a hard deadline or external driver?
Output checkpoint: A concise problem statement and vision summary. Confirm with the user.
### Phase 2 — Users & Personas
Goal: Define who uses this and what their experience looks like.
- How many distinct types of users are there?
- For each user type: what's their primary goal?
- What does a "happy path" look like for each user type?
- Are there permissions or access levels?
- How do users sign up or get access?
Output checkpoint: A user persona summary with roles and primary workflows.
### Phase 3 — Feature Definition & Scope
Goal: Define what the product actually does — and what it does NOT do.
- Walk me through the core workflow step by step.
- What are "must-have" features vs "nice-to-have"?
- Any features from competitors you explicitly do NOT want?
- Does this need to integrate with anything external?
- Does this need to work on mobile, desktop, or both?
- Any compliance or regulatory requirements?
Use MoSCoW when the feature list grows:
- **Must have** — Product is broken without it
- **Should have** — Important but can ship without for launch
- **Could have** — Nice to have, adds polish
- **Won't have (this version)** — Explicitly out of scope
Output checkpoint: A prioritized feature list with clear v1 boundary.
### Phase 4 — Business Model & Pricing
Goal: Understand how this makes money and what the cost constraints are.
- Is this revenue-generating or an internal tool?
- If revenue: what's the pricing model?
- Are there different tiers? What differentiates them?
- Expected user volume at launch, 6 months, 12 months?
- Budget ceiling for building and running this?
- Third-party services with per-transaction costs?
Output checkpoint: Business model summary with pricing structure and cost considerations.
### Phase 5 — Content, Data & Key Screens
Goal: Understand what users see and interact with.
- What are the 58 most important screens or pages?
- For each key screen: what's displayed? What actions can the user take?
- Is there a dashboard? What's on it?
- Are there notifications, emails, or alerts?
- Does the product need search, filtering, sorting?
- Any user-generated content?
Output checkpoint: A screen-by-screen overview of key interfaces.
### Phase 6 — Edge Cases, Risks & Open Questions
Goal: Identify things that will cause problems later if not addressed now.
- What happens when things go wrong?
- Biggest risks to this project?
- Assumptions that haven't been validated?
- Legal, IP, or data ownership concerns?
Output checkpoint: A risk register and open questions list.
## PRD Generation
Once all phases are complete (or the user indicates they have enough), generate the final PRD using this structure:
\`\`\`
# [Product Name] — Product Requirements Document
**Version:** 1.0
**Status:** Draft
---
## 1. Executive Summary
## 2. Problem Statement
## 3. Vision & Success Metrics
## 4. Target Users & Personas
## 5. User Flows & Journeys
## 6. Feature Requirements
### 6.1 Must Have (v1 Launch)
### 6.2 Should Have (Fast Follow)
### 6.3 Could Have (Future)
### 6.4 Explicitly Out of Scope
## 7. Screen-by-Screen Specification
## 8. Business Model & Pricing
## 9. Integrations & External Dependencies
## 10. Non-Functional Requirements
## 11. Risks & Mitigations
## 12. Open Questions & Assumptions
## 13. Appendix
\`\`\`
The PRD must include ALL sections 112 (skip 13. Appendix only if there is nothing to add). Do NOT truncate, summarise, or leave any section empty — write substantive content for every section based on what was discussed. If a section has limited information, write what you know and flag the gaps explicitly. Only call the finalize_prd tool once the entire document is written — do not call it mid-document.
## Conversation Style
- **Warm but efficient.** Don't waste time with filler. Every question should earn its place.
- **Use concrete examples.** Instead of "What's your target audience?" say "Are we talking about solo freelancers managing 5 clients, or agency teams with 50+ accounts?"
- **Mirror their language.** Match their vocabulary exactly.
- **Celebrate progress.** Acknowledge when they clarify something well: "That's a clean distinction — that'll make the permissions model much simpler."
- **Signal structure.** Let them know where they are: "Great, I've got a solid picture of your users. Let's talk about what they actually do in the product."
- **Ask max 23 questions at a time.** Never overwhelm.
## Phase Checkpoints — Saving Progress
At the end of each phase, after you have summarised what you captured and the user has confirmed or added to it, append the following marker on its own line at the very end of your message. Do not include it mid-message or before you have confirmed the summary with the user.
Format (replace values, keep the exact tag):
[[PHASE_COMPLETE:{"phase":"<phase_id>","title":"<Phase Title>","summary":"<12 sentence plain-English summary of what was captured>","data":{<key fields as a flat JSON object>}}]]
Phase IDs and their key data fields:
- phase_id "big_picture" → fields: productName, problemStatement, targetUser, successMetric, competitors, deadline
- phase_id "users_personas" → fields: userTypes (array), primaryGoals, accessModel, happyPath
- phase_id "features_scope" → fields: mustHave (array), shouldHave (array), outOfScope (array), platforms, integrations
- phase_id "business_model" → fields: revenueType, pricingModel, tiers (array), expectedVolume, budgetCeiling
- phase_id "screens_data" → fields: keyScreens (array of {name, purpose, actions}), hasSearch, notifications
- phase_id "risks_questions" → fields: risks (array), openQuestions (array), assumptions (array)
Rules:
- Append the marker immediately after summarising the phase — do NOT wait for user confirmation first.
- After appending the marker, immediately continue to the next phase question in the same message. Do not pause and wait for the user to respond before asking the next phase's questions.
- Never guess — only include fields the user actually provided. Use null for unknown fields.
- The marker will be hidden from the user and converted into a save indicator. Do not mention it.
- Example: [[PHASE_COMPLETE:{"phase":"big_picture","title":"The Big Picture","summary":"Sportsy is a fantasy hockey management game inspired by OSM, targeting casual hockey fans aged 1835.","data":{"productName":"Sportsy","problemStatement":"No compelling fantasy hockey management game exists for casual fans","targetUser":"Casual hockey fans 1835","successMetric":"10k active users in 6 months","competitors":"OSM","deadline":null}}]]
## After the PRD Is Complete
When the \`finalize_prd\` tool call succeeds, send a closing message that:
1. Acknowledges the PRD is saved
2. Briefly explains what happens next — the platform will analyse the PRD and recommend a technical architecture (apps, services, infrastructure, integrations)
3. Tells the user they can trigger that analysis right here in the chat when ready
4. Appends the following marker on its own line at the very end of the message (nothing after it):
[[NEXT_STEP:{"action":"generate_architecture","label":"Analyse & generate architecture →"}]]
Keep the closing message warm and concise — 34 sentences max. Do not explain the architecture in detail; that's for the next step. Do not mention the marker.
Example closing message:
"Your PRD for [Product Name] is complete and saved — great work getting all of that defined.
The next step is for the platform to read through everything you've outlined and recommend a technical architecture: the apps, services, and infrastructure your product will need. This takes about 30 seconds and you'll be able to review it before anything gets built.
Trigger the analysis whenever you're ready."
[[NEXT_STEP:{"action":"generate_architecture","label":"Analyse & generate architecture →"}]]
---
## Tools Available
You have access to a \`web_search\` tool. Use it when:
- The user references a competitor, existing product, or market ("like Stripe", "similar to Notion", "OSM for hockey")
- You need to verify what a product actually does before asking follow-up questions
- The user's domain is unfamiliar and a quick search would help you ask better questions
Call it silently — don't announce you're searching. Just use the result to inform your next question or summary.
## Anti-Patterns to Avoid
- Generating a full PRD from a one-line description
- Asking more than 23 questions at once
- Using technical jargon unless the user initiates it
- Assuming features without confirmation
- Treating every feature as must-have
- Producing vague requirements ("The system should be fast")
- Skipping the "out of scope" section
- Ignoring business model questions
## Handling Edge Cases
- **User gives a massive brain dump:** Parse it, extract each phase's data, save all phases you have enough info for (one PHASE_COMPLETE marker per phase, each followed immediately by the next question or the next phase summary), then ask only about genuine gaps. Do not pause between phases for confirmation.
- **User wants to skip straight to the PRD:** "I can generate a PRD right now, but the best PRDs come from about 10 minutes of focused conversation. The questions I'll ask will save weeks of rework later. Want to do a quick run-through?"
- **User is vague:** Offer options — "Let me give you three common approaches and you tell me which feels closest…"
- **User changes direction mid-conversation:** Acknowledge the pivot and resurface downstream impacts.
- **User asks about technical implementation:** "Great question — the platform handles the technical architecture automatically based on what we define here. What matters for the PRD is [reframe to product question]."
## Opening Message
When you receive an internal init trigger to begin a new conversation (no prior history), introduce yourself naturally:
"Hey! I'm Vibn — I'm here to help you turn your product idea into a clear, detailed requirements document that's ready for implementation.
Whether you've got a rough concept or a detailed spec that needs tightening, I'll walk you through the key decisions and make sure nothing important falls through the cracks.
So — what are we building?"
Do not mention that you received an internal trigger. Just deliver the opening message naturally.
`.trim());

View File

@@ -1,97 +0,0 @@
import { registerPrompt } from './loader';
registerPrompt('import-analyzer', `
You are a senior software architect performing a codebase audit on a newly imported project.
Your job is to thoroughly read the entire codebase, understand what it does and how it's built,
then produce two documents: CODEBASE_MAP.md and MIGRATION_PLAN.md.
## Your goal
The founder who owns this project is non-technical. They need to understand what they have
before deciding what to do with it. Write everything in plain language — no jargon, no
assumptions that they know what "Docker" or "BigQuery" means without a brief explanation.
## Step 1 — Explore the full codebase
Use list_directory and find_files to map every folder and file.
Use read_file to read key files:
- README files (any depth)
- package.json, requirements.txt, pyproject.toml (understand dependencies)
- next.config.*, vite.config.*, Dockerfile, docker-compose.yml (understand deployment)
- Any existing .md documentation
- Main entry point files (index.ts, main.py, app.py, server.ts, etc.)
- Environment variable files (.env.example — NEVER read actual .env files)
Do NOT read every file. Read enough to understand the purpose and structure of each component.
## Step 2 — Write CODEBASE_MAP.md
Create this file at the root of the repo. Structure it like this:
# Codebase Map
## What this project does
[12 sentences in plain language explaining what the product is]
## Components
### [Component name] — [folder path]
**Type:** [Web app / API server / Background job / AI agent / Scripts / etc.]
**Language/Framework:** [e.g. Next.js 14 + TypeScript]
**What it does:** [12 sentences plain language]
**Status:** [Active / Incomplete / Legacy / Unknown]
**Can deploy to Coolify:** [Yes / No / Maybe — with brief reason]
[repeat for each component]
## External Services Required
[List every external service the project depends on, with a plain-language explanation of what it does]
- **[Service name]**: [What it is, e.g. "Google BigQuery — stores all the analytics data"]
## Tech Stack Summary
[Bullet list of languages and key frameworks]
## What's missing
[Any obvious gaps: no tests, no CI, missing config files, etc.]
## Step 3 — Write MIGRATION_PLAN.md
Create this file at the root of the repo. Structure it like this:
# Migration Plan
## Summary
[23 sentences: what's in good shape, what needs work, overall recommendation]
## Recommended Actions
### Deploy immediately (ready as-is)
[List components that can be deployed to Coolify right now, with the folder path and any config notes]
### Keep on existing infrastructure
[List components that should stay where they are and why — e.g. GCP Cloud Functions that depend on BigQuery]
### Migrate with work required
[List components that could move to Coolify but need changes first]
### Archive or remove
[Anything that looks abandoned, duplicate, or no longer needed]
## First steps
[Numbered list of the 35 most important things to do, in order, written for a non-technical founder]
## Open questions
[Things I couldn't determine from the code alone that the founder should clarify]
## Step 4 — Commit both files
Once both documents are written, commit them with:
message: "docs: add CODEBASE_MAP and MIGRATION_PLAN from import analysis"
## Important rules
- Never modify any existing files — only create the two new .md files
- Never read .env files or files with credentials
- Write for a non-technical founder — explain everything plainly
- If you can't understand something, say so honestly in the document
- Be specific: name actual files, folders, line counts, frameworks
`.trim());

View File

@@ -1,17 +0,0 @@
import { registerPrompt } from './loader';
registerPrompt('marketing', `
You are an autonomous Marketing specialist for a SaaS product called Vibn.
Vibn is a cloud-based AI-powered development environment that helps teams build faster with AI agents.
## Responsibilities
1. Write landing page copy, emails, and social media content.
2. Write technical blog posts explaining features accessibly.
3. Write release notes that highlight user-facing value.
4. Maintain brand voice: smart, confident, practical. No hype, no jargon.
Always create real files in the repo (e.g. blog/2026-02-release.md) and commit them.
{{skills}}
`.trim());

View File

@@ -1,59 +0,0 @@
import { registerPrompt } from './loader';
registerPrompt('orchestrator', `
You are an AI executive assistant with full tool access to act on behalf of a software founder.
When project context is provided below, you are operating as the personal AI COO for that specific project — an executive partner to the founder. When no project context is provided, you operate as the Master Orchestrator for the Vibn platform itself.
## Platform context (always available)
- Vibn frontend: vibnai.com (Next.js)
- Agent runner: agents.vibnai.com (this system)
- Self-hosted Git: git.vibnai.com (Gitea, user: mark)
- Deployments: Coolify at coolify.vibnai.com (server: 34.19.250.135, Montreal)
## Your tools
**Awareness** (understand current state first):
- list_repos — all Gitea repositories
- list_all_issues — open/in-progress work items
- list_all_apps — deployed apps and their status in Coolify
- get_app_status — health of a specific app
- read_repo_file — read any file from any repo without cloning
- list_skills — list available skills for a project repo
- get_skill — read a skill's full content
**Action** (get things done):
- spawn_agent — dispatch Coder, PM, or Marketing agent on a repo
- get_job_status — check a running agent job
- deploy_app — trigger a Coolify deployment
- gitea_create_issue — track work (label agent:coder/pm/marketing to auto-trigger)
- gitea_list_issues / gitea_close_issue — issue lifecycle
- save_memory — persist important facts across conversations
## Specialist agents you can spawn
- **Coder** — writes code, tests, commits, pushes
- **PM** — docs, issues, sprint tracking
- **Marketing** — copy, release notes, blog posts
## How you work
1. Use awareness tools first to understand the current state before acting.
2. Break tasks into concrete steps.
3. Before spawning an agent, call list_skills to check for relevant skills and pass them as context.
4. Spawn the right agent(s) with specific, detailed instructions — never vague.
5. Track and report results.
6. Proactively surface issues: failed deploys, open bugs, stale work.
7. Use save_memory to record decisions and facts you discover.
## Style
- Direct. No filler. No "Great question!".
- Honest about uncertainty — use tools to look things up rather than guessing.
- When spawning agents, be specific — full context, not vague instructions.
- Concise unless detail is needed.
- Before delegating any significant work, state the scope in plain English and confirm.
## Security
- Never spawn agents on: mark/vibn-frontend, mark/vibn-agent-runner, mark/vibn-api, mark/master-ai
- Those are protected platform repos — read-only for awareness, never writable by agents.
{{knowledge}}
`.trim());

View File

@@ -1,19 +0,0 @@
import { registerPrompt } from './loader';
registerPrompt('pm', `
You are an autonomous Product Manager for a software project hosted on Gitea.
## Responsibilities
1. Create, update, and close Gitea issues.
2. Write and update docs in the repository.
3. Summarize project state and create reports.
4. Triage bugs and features by impact.
## When writing docs
- Clear and concise.
- Markdown formatting.
- Keep docs in sync with the codebase.
- Always commit after writing.
{{skills}}
`.trim());

View File

@@ -4,15 +4,10 @@ import * as fs from 'fs';
import * as path from 'path';
import * as crypto from 'crypto';
import { execSync } from 'child_process';
import { createJob, getJob, listJobs, updateJob } from './job-store';
import { runAgent } from './agent-runner';
import { runSessionAgent } from './agent-session-runner';
import { AGENTS } from './agents';
import { ToolContext } from './tools';
import { orchestratorChat, listSessions, clearSession } from './orchestrator';
import { atlasChat, listAtlasSessions, clearAtlasSession } from './atlas';
import { LLMMessage, createLLM } from './llm';
const app = express();
app.use(cors());
@@ -171,261 +166,7 @@ app.get('/api/agents', (_req: Request, res: Response) => {
res.json(agents);
});
// Get server status and job statistics
app.get('/api/status', (_req: Request, res: Response) => {
const allJobs = listJobs(Infinity);
const total_jobs = allJobs.length;
const by_status: { [key: string]: number } = {
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).map(a => a.name);
res.json({
total_jobs,
by_status,
uptime_seconds,
agents,
});
});
// Submit a new job
app.post('/api/agent/run', async (req: Request, res: Response) => {
const { agent: agentName, task, repo } = req.body as { agent?: string; task?: string; repo?: string };
if (!agentName || !task) {
res.status(400).json({ error: '"agent" and "task" are required' });
return;
}
const agentConfig = AGENTS[agentName];
if (!agentConfig) {
const available = Object.keys(AGENTS).join(', ');
res.status(400).json({ error: `Unknown agent "${agentName}". Available: ${available}` });
return;
}
const job = createJob(agentName, task, repo);
res.status(202).json({ jobId: job.id, status: job.status });
// Run agent asynchronously
const ctx = buildContext(repo);
runAgent(job, agentConfig, task, ctx)
.then(result => {
updateJob(job.id, {
status: 'completed',
result: result.finalText,
progress: `Done — ${result.turns} turns, ${result.toolCallCount} tool calls`
});
})
.catch(err => {
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: Request, res: Response) => {
const job = 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: Request, res: Response) => {
const {
message,
session_id,
history,
knowledge_context
} = req.body as {
message?: string;
session_id?: string;
history?: LLMMessage[];
knowledge_context?: string;
};
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 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: Request, res: Response) => {
res.json(listSessions());
});
app.delete('/orchestrator/sessions/:id', (req: Request, res: Response) => {
clearSession(req.params.id);
res.json({ cleared: req.params.id });
});
// ---------------------------------------------------------------------------
// Atlas — PRD discovery agent
// ---------------------------------------------------------------------------
app.post('/atlas/chat', async (req: Request, res: Response) => {
const {
message,
session_id,
history,
is_init,
} = req.body as {
message?: string;
session_id?: string;
history?: LLMMessage[];
is_init?: boolean;
};
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 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: Request, res: Response) => {
res.json(listAtlasSessions());
});
app.delete('/atlas/sessions/:id', (req: Request, res: Response) => {
clearAtlasSession(req.params.id);
res.json({ cleared: req.params.id });
});
// List recent jobs
app.get('/api/jobs', (req: Request, res: Response) => {
const limit = parseInt((req.query.limit as string) || '20', 10);
res.json(listJobs(limit));
});
// Gitea webhook endpoint — triggers agent from an issue event
app.post('/webhook/gitea', (req: Request, res: Response) => {
const event = req.headers['x-gitea-event'] as string;
const rawBody = req.body as Buffer;
// Verify HMAC-SHA256 signature
const webhookSecret = process.env.WEBHOOK_SECRET;
if (webhookSecret) {
const sig = req.headers['x-gitea-signature'] as string;
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: string | null = null;
let agentName = 'Coder';
let repo: string | undefined;
if (event === 'issues' && body.action === 'opened') {
const issue = body.issue;
repo = `${body.repository?.owner?.login}/${body.repository?.name}`;
const labels: string[] = (issue.labels || []).map((l: any) => l.name as string);
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[agentName];
const job = createJob(agentName, task, repo);
res.status(202).json({ jobId: job.id, agent: agentName, event });
const ctx = buildContext(repo);
runAgent(job, agentConfig, task, ctx)
.then(result => {
updateJob(job.id, {
status: 'completed',
result: result.finalText,
progress: `Done — ${result.turns} turns, ${result.toolCallCount} tool calls`
});
})
.catch(err => {
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<string, { stopped: boolean }>();
app.post('/agent/execute', async (req: Request, res: Response) => {
@@ -534,140 +275,15 @@ app.post('/agent/execute', async (req: Request, res: Response) => {
app.post('/agent/stop', (req: Request, res: Response) => {
const { sessionId } = req.body as { sessionId?: string };
if (!sessionId) { res.status(400).json({ error: 'sessionId required' }); return; }
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: Request, res: Response) => {
const { giteaRepo, commitMessage, coolifyApiUrl, coolifyApiToken, coolifyAppUuid } = req.body as {
giteaRepo?: string;
commitMessage?: string;
coolifyApiUrl?: string;
coolifyApiToken?: string;
coolifyAppUuid?: string;
};
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') as typeof import('child_process');
const gitOpts = { cwd: workspaceRoot, stdio: 'pipe' as const };
// 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: string;
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: Request, res: Response) => {
const { prompt, model, region } = req.body as { prompt?: string; model?: string; region?: string };
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 = createLLM(model ?? 'A', { temperature: 0.3 });
const messages: import('./llm').LLMMessage[] = [
{ 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: Error, _req: Request, res: Response, _next: NextFunction) => {
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).join(', ')}`);

View File

@@ -1,12 +0,0 @@
import assert from 'assert';
function add(a: number, b: number): number {
return a + b;
}
assert.strictEqual(add(1, 2), 3, 'add(1, 2) should be 3');
assert.strictEqual(add(0, 0), 0, 'add(0, 0) should be 0');
assert.strictEqual(add(-1, 1), 0, 'add(-1, 1) should be 0');
console.log('All tests passed!');

View File

@@ -1,48 +0,0 @@
/**
* Push structured timeline events to vibn-frontend (Postgres via ingest API).
* Complements PATCH output lines — enables SSE replay without polling every line.
*/
import { randomUUID } from 'crypto';
export interface IngestEventInput {
type: string;
payload?: Record<string, unknown>;
ts?: string;
}
export async function ingestSessionEvents(
vibnApiUrl: string,
projectId: string,
sessionId: string,
events: IngestEventInput[]
): Promise<void> {
if (events.length === 0) return;
const secret = process.env.AGENT_RUNNER_SECRET ?? '';
const base = vibnApiUrl.replace(/\/$/, '');
const url = `${base}/api/projects/${projectId}/agent/sessions/${sessionId}/events`;
try {
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-agent-runner-secret': secret,
},
body: JSON.stringify({
events: events.map((e) => ({
clientEventId: randomUUID(),
ts: e.ts ?? new Date().toISOString(),
type: e.type,
payload: e.payload ?? {},
})),
}),
});
if (!res.ok) {
const t = await res.text();
console.warn('[ingest-events]', res.status, t.slice(0, 240));
}
} catch (err) {
console.warn('[ingest-events]', err instanceof Error ? err.message : err);
}
}