import fs from 'fs'; import path from 'path'; import { LLMOrbiter, OpenRouterProvider, defineTool, ToolError } from 'libllmorbiter'; import env from '../config/env.js'; import * as db from '../db/modelconversation.js'; import { emit as emitCompanyEvent } from '../api/sse.js'; import { buildSystemPrompt } from '../services/prompts.js'; import { historyToLlmMessages } from '../services/conversationManager.js'; import { resolveOpenRouterKey, resolveModelReasoning } from './llm-key.js'; import { TOOL_DESCRIPTIONS, TOOL_PARAMETER_DESCRIPTIONS } from './prompts.js'; import { SKILL_EXT, isSkillName, parseSkill } from './skill.js'; import { getApiRouteSkill } from './routes-skills.js'; import { prepareVfs } from './vfs.js'; import { createDashbackRequestTool } from './dashback-request.js'; import { createJavascriptExecuteTool } from './javascript.js'; import { createFindFilesTool } from './find-files.js'; import { createReadFileTool } from './read-file.js'; import { createPatchTool } from './patch.js'; import { createRmTool } from './rm.js'; import { createSearchImagesTool } from './search-images.js'; import { createAnalyzeImageTool } from './analyze-image.js'; import { createEditImageTool } from './edit-image.js'; import { createWebSearchTool } from './web-search.js'; import { createUrlGetTool } from './url-get.js'; import { createPdfReadTool } from './pdf-read.js'; import { createResetVfsTool } from './reset-vfs.js'; import { createApiSuggestionsOrIssuesTool } from './apisuggestionsorissues.js'; import { createSearchArticlesTool } from './search-articles.js'; import { createEanChecksumTool } from './ean-checksum.js'; import { createNetGrossCalcTool } from './net-gross-calc.js'; /** * subagent — delegates a self-contained task to an isolated LLMOrbiter run that lives in * its own persisted conversation (`modelconversation` / `modelmessage`). * * The subagent gets the parent's toolbelt minus `ask_user`, `subagent` and `set_user_route` * (it cannot talk to the user, spawn further subagents or drive the UI). The parent only * receives the final output plus a byte-level audit of the tool calls; the full transcript * stays in the subagent's conversation. */ // #region Constants const DEFAULT_MAX_ITERATIONS = 15; const MAX_ITERATIONS_LIMIT = 30; /** Subagents never spawn subagents: the parent runs at depth 0, its child at depth 1. */ const MAX_SUBAGENT_DEPTH = 1; const SUBAGENT_PROVIDER = 'openrouter'; const SUBAGENT_PROMPT_NOTICE = [ 'SUBAGENT MODE:', '- You are a subagent working for a primary agent, not talking to the end user. You cannot ask the user questions.', '- Complete the task with your tools, then finish with a concise, self-contained final answer: what was done, key findings, and any file paths or IDs the primary agent needs.', '- Do not ask for confirmation; if the task is ambiguous, state your assumption and proceed.', ].join('\n'); // #endregion // #region Skill Injection /** * Turns a skill name or slug into its flat SKILL file name: `api-article` → `api-article.SKILL`. * @param {string} nameOrSlug * @returns {string} */ function toSkillFileName(nameOrSlug) { const trimmed = String(nameOrSlug ?? '').trim(); return trimmed.toLowerCase().endsWith(SKILL_EXT.toLowerCase()) ? trimmed : `${trimmed}${SKILL_EXT}`; } /** * Finds a SKILL file by name: the company's VFS root first, then the virtual * `api-
.SKILL` files generated from docs/api/routes.md. * @param {string} vfsRoot * @param {string} fileName * @returns {string|null} raw SKILL content, or null when no such skill exists */ function readSkillContent(vfsRoot, fileName) { const diskPath = path.join(vfsRoot, fileName); if (fs.existsSync(diskPath) && fs.statSync(diskPath).isFile()) { return fs.readFileSync(diskPath, 'utf8'); } return getApiRouteSkill(fileName)?.content ?? null; } /** * Resolves the requested skills and renders them as an `# INJECTED SKILLS` prompt block. * Unknown skills are rejected as a whole so the primary agent can correct the call * instead of delegating to a subagent that silently lacks its guidelines. * * @param {string} vfsRoot * @param {string[]|null|undefined} skillNames * @returns {string} empty string when no skills were requested * @throws {ToolError} on malformed or unknown skill names */ function buildInjectedSkillsBlock(vfsRoot, skillNames) { if (skillNames == null) return ''; if (!Array.isArray(skillNames)) throw new ToolError('"skills" must be an array of skill names or null.'); const fileNames = [...new Set(skillNames.map(toSkillFileName))]; const invalid = fileNames.filter((fileName) => !isSkillName(fileName)); if (invalid.length > 0) { throw new ToolError(`Invalid skill name(s): ${invalid.join(', ')}. Skills are flat names like "api-article".`); } const sections = []; const missing = []; for (const fileName of fileNames) { const content = readSkillContent(vfsRoot, fileName); if (content == null) { missing.push(fileName); continue; } const { title, markdown } = parseSkill(content); sections.push(`## ${title} (${fileName})\n\n${markdown}`); } if (missing.length > 0) { throw new ToolError( `Unknown skill(s): ${missing.join(', ')}. Call find_files({ skills: true }) to list available skills.` ); } return sections.length > 0 ? `# INJECTED SKILLS\n\n${sections.join('\n\n')}` : ''; } // #endregion // #region Conversation Guards /** * Rejects a follow-up when the target thread is not in a state where appending a user * message is a legal turn: it must be idle, and its last conversational message must * not already be a `user` message awaiting a response. * * @param {object} conversation * @param {Array<{ role: string }>} history * @throws {ToolError} */ function assertConversationAcceptsTask(conversation, history) { if (conversation.state !== 'idle') { throw new ToolError(`Subagent conversation ${conversation.id} is busy; wait for it to finish.`); } // `usage` and `reasoning` rows are bookkeeping, not conversational turns. const lastTurn = [...history].reverse().find((m) => m.role !== 'usage' && m.role !== 'reasoning'); if (lastTurn?.role === 'user') { throw new ToolError( `Subagent conversation ${conversation.id} is already awaiting a response (last message is a user message).` ); } } // #endregion // #region Helpers function stringifyToolOutput(result) { return typeof result === 'string' ? result : JSON.stringify(result ?? ''); } /** * Builds the subagent toolbelt: the parent's tools without `ask_user`, `subagent` and * `set_user_route`. */ function createSubagentTools({ googleAuthId, companyId, model }) { return [ createDashbackRequestTool({ googleAuthId }), createJavascriptExecuteTool({ googleAuthId }), createFindFilesTool({ googleAuthId }), createReadFileTool({ googleAuthId }), createPatchTool({ googleAuthId }), createRmTool({ googleAuthId }), createSearchImagesTool({ googleAuthId }), createAnalyzeImageTool({ googleAuthId, model }), createEditImageTool({ googleAuthId, model }), createWebSearchTool({ googleAuthId }), createUrlGetTool({ googleAuthId }), createPdfReadTool({ googleAuthId }), createResetVfsTool({ googleAuthId }), createApiSuggestionsOrIssuesTool({ googleAuthId, companyId }), createSearchArticlesTool({ googleAuthId, companyId }), createEanChecksumTool(), createNetGrossCalcTool(), ]; } // #endregion // #region Factory /** * Creates the `subagent` tool. * * @param {{ * googleAuthId: number, * companyId: number, * conversationId: number, * parentModel: string, * depth?: number, * }} ctx `conversationId` is the active parent conversation (target of the self-talk guard); * `parentModel` is the parent's resolved model, inherited by the subagent. */ // MARK: createSubagentTool — guarded subagent execution loop export function createSubagentTool({ googleAuthId, companyId, conversationId: parentConversationId, parentModel, depth = 0 }) { return defineTool({ name: 'subagent', description: TOOL_DESCRIPTIONS.subagent, strict: false, parameters: { type: 'object', additionalProperties: false, required: ['task'], properties: { task: { type: 'string', description: TOOL_PARAMETER_DESCRIPTIONS.subagent.task }, skills: { type: ['array', 'null'], items: { type: 'string' }, description: TOOL_PARAMETER_DESCRIPTIONS.subagent.skills, }, conversation_id: { type: ['integer', 'null'], description: TOOL_PARAMETER_DESCRIPTIONS.subagent.conversation_id, }, max_iterations: { type: ['integer', 'null'], description: TOOL_PARAMETER_DESCRIPTIONS.subagent.max_iterations, }, note: { type: ['string', 'null'], description: TOOL_PARAMETER_DESCRIPTIONS.subagent.note }, }, }, invoke: async ({ task, skills = null, conversation_id: targetConversationId = null, max_iterations: maxIterations = null } = {}, { signal } = {}) => { // MARK: validate input and guards before touching the database if (depth >= MAX_SUBAGENT_DEPTH) { throw new ToolError('Subagents cannot spawn further subagents.'); } if (typeof task !== 'string' || !task.trim()) { throw new ToolError('"task" must be a non-empty string.'); } if (targetConversationId != null && targetConversationId === parentConversationId) { throw new ToolError('Cannot target active parent conversation'); } const iterationLimit = maxIterations != null ? Number(maxIterations) : DEFAULT_MAX_ITERATIONS; if (!Number.isInteger(iterationLimit) || iterationLimit < 1) { throw new ToolError(`"max_iterations" must be a positive integer (received ${maxIterations}).`); } const boundedIterations = Math.min(iterationLimit, MAX_ITERATIONS_LIMIT); const vfsRoot = await prepareVfs({ googleAuthId }); const injectedSkills = buildInjectedSkillsBlock(vfsRoot, skills); // MARK: create or resume the subagent conversation let conversation; let history = []; if (targetConversationId != null) { conversation = await db.get(googleAuthId, targetConversationId); history = await db.listMessages(googleAuthId, targetConversationId); if (conversation.started_by !== parentConversationId) { throw new ToolError(`Conversation ${targetConversationId} is not a subagent conversation started by this conversation.`); } assertConversationAcceptsTask(conversation, history); } else { conversation = await db.create(googleAuthId, { provider: SUBAGENT_PROVIDER, model: parentModel, started_by: parentConversationId }); emitCompanyEvent('modelconversation', { companyId }); } const subagentConversationId = conversation.id; // Persist the task as a user turn (DB refuses unless idle), then mark busy. await db.addMessage(googleAuthId, subagentConversationId, { role: 'user', message: JSON.stringify({ content: task.trim() }), }); await db.setState(googleAuthId, subagentConversationId, 'busy'); emitCompanyEvent('modelconversation', { companyId }); const toolCalls = []; const toolCallsById = new Map(); let streamedText = ''; let streamedReasoning = ''; const saveMessage = async (role, payload, label) => { try { await db.addMessage(googleAuthId, subagentConversationId, { role, message: JSON.stringify(payload) }); emitCompanyEvent('modelconversation', { companyId }); } catch (err) { console.error(`[subagent ${subagentConversationId}] failed to save ${label}:`, err); } }; try { const apiKey = await resolveOpenRouterKey(googleAuthId); const reasoningEffort = await resolveModelReasoning(googleAuthId); const fullHistory = await db.listMessages(googleAuthId, subagentConversationId); const systemPrompt = [ await buildSystemPrompt(vfsRoot, googleAuthId), SUBAGENT_PROMPT_NOTICE, injectedSkills, ].filter(Boolean).join('\n\n'); const orbiter = new LLMOrbiter(new OpenRouterProvider({ apiKey, apiUrl: env.openrouterApiUrl }), { onReasoning: (reasoning) => { streamedReasoning = reasoning; }, onContent: (chunk) => { streamedText += chunk; }, onAssistantMessage: async ({ content, tool_calls, reasoning_details, reasoning }) => { const plain = (reasoning || streamedReasoning.trim() || reasoning_details?.find((d) => d?.type === 'reasoning.summary')?.summary || '').trim(); const encrypted = reasoning_details?.find((d) => d?.type === 'reasoning.encrypted')?.data || ''; streamedReasoning = ''; if (plain || encrypted) { await saveMessage('reasoning', { plain, encrypted }, 'reasoning message'); } const trimmedContent = (content || streamedText).trim(); const hasToolCalls = Array.isArray(tool_calls) && tool_calls.length > 0; if (trimmedContent || hasToolCalls) { await saveMessage('assistant', { content: trimmedContent, ...(hasToolCalls ? { tool_calls } : {}), }, 'assistant message'); streamedText = ''; } }, onToolCall: async ({ name, args, id, callItem }) => { const rawArgs = callItem?.function?.arguments || (typeof args === 'string' ? args : JSON.stringify(args || {})); const entry = { name, input_bytes: Buffer.byteLength(rawArgs), output_bytes: 0 }; toolCalls.push(entry); toolCallsById.set(id || callItem?.id || `${name}#${toolCalls.length}`, entry); console.log(`[subagent ${subagentConversationId}] tool call: ${name} args=${rawArgs}`); }, onToolResult: async ({ name, result, id, callItem }) => { const rawArgs = callItem?.function?.arguments || ''; const output = stringifyToolOutput(result); const entry = toolCallsById.get(id || callItem?.id); if (entry) entry.output_bytes = Buffer.byteLength(output); await saveMessage('tool', { name, parameters: rawArgs, content: output, tool_call_id: id || callItem?.id || '', }, 'tool result message'); }, onToolError: async ({ name, error, id, callItem }) => { const rawArgs = callItem?.function?.arguments || ''; const output = `Tool error [${name}]: ${error}`; const entry = toolCallsById.get(id || callItem?.id); if (entry) entry.output_bytes = Buffer.byteLength(output); await saveMessage('tool', { name, parameters: rawArgs, content: output, tool_call_id: id || callItem?.id || '', }, 'tool error message'); }, onUsage: async (u) => { if (!u) return; await saveMessage('usage', { input: u.input ?? 0, cached: u.cached ?? 0, output: u.output ?? 0, ...(u.cost != null ? { cost: u.cost } : {}), }, 'usage message'); }, onLLMCall: (call) => { console.log( `[subagent ${subagentConversationId}] LLM call #${call.iteration}: ` + `model=${call.model}, messages=${call.messageCount}, promptChars=${call.promptSize}` ); }, }); const result = await orbiter.run({ messages: [ { role: 'system', content: systemPrompt }, ...historyToLlmMessages(fullHistory), ], model: parentModel, maxIterations: boundedIterations, extraBody: reasoningEffort ? { reasoning: { effort: reasoningEffort } } : {}, tools: createSubagentTools({ googleAuthId, companyId, model: parentModel }), signal, }); const output = (result.content ?? '').trim(); return { conversation_id: subagentConversationId, status: output ? 'completed' : 'empty', output, tool_calls: toolCalls, iterations: result.iterations, usage: { input: result.usage?.input ?? 0, output: result.usage?.output ?? 0, cached: result.usage?.cached ?? 0, }, }; } catch (err) { if (err instanceof ToolError) throw err; console.error(`[subagent ${subagentConversationId}] run failed:`, err); throw new ToolError(`Subagent conversation ${subagentConversationId} failed: ${err.message}`); } finally { try { await db.setState(googleAuthId, subagentConversationId, 'idle'); } catch (err) { console.error(`[subagent ${subagentConversationId}] failed to reset state:`, err); } emitCompanyEvent('modelconversation', { companyId }); } }, }); } // #endregion