import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { Ollama, type Message, type ToolCall } from "ollama"; import { log } from "../utils/cli.ts"; import { agent, type AgentResult, type AgentRunContext } from "./shared.ts"; const DEFAULT_MODEL = "qwen3.6:35b"; const MAX_ITERATIONS = 100; interface OllamaTool { type: "function"; function: { name: string; description: string; parameters: Record; }; } async function buildMcpClient(mcpServerUrl: string): Promise { const client = new Client( { name: "shockbot-agent", version: "0.1.0" }, { capabilities: {} }, ); const transport = new StreamableHTTPClientTransport(new URL(mcpServerUrl)); await client.connect(transport); return client; } async function getOllamaTools(mcpClient: Client): Promise { const { tools } = await mcpClient.listTools(); return tools.map((t) => ({ type: "function" as const, function: { name: t.name, description: t.description ?? "", parameters: (t.inputSchema as Record) ?? { type: "object", properties: {}, }, }, })); } async function callMcpTool( mcpClient: Client, toolName: string, args: Record, ): Promise { try { const result = await mcpClient.callTool({ name: toolName, arguments: args, }); const content = result.content as | Array<{ type: string; text?: string }> | undefined; if (!content || content.length === 0) return JSON.stringify({ success: true }); const text = content .map((c) => (c.type === "text" ? (c.text ?? "") : "")) .filter(Boolean) .join("\n"); return text || JSON.stringify(result); } catch (err) { const msg = err instanceof Error ? err.message : String(err); log.debug(`Tool ${toolName} error: ${msg}`); return JSON.stringify({ error: msg }); } } async function runOllamaLoop(ctx: AgentRunContext): Promise { const ollamaHost = process.env.OLLAMA_HOST ?? ""; if (!ollamaHost) { const errorMsg = "OLLAMA_HOST environment variable is not set. Please set it to the URL of your Ollama instance."; log.error(errorMsg); return { success: false, error: errorMsg }; } const model = ctx.model ?? process.env.OLLAMA_MODEL ?? DEFAULT_MODEL; log.info(`» connecting to Ollama at ${ollamaHost}, model ${model}`); const ollama = new Ollama({ host: ollamaHost }); const mcpClient = await buildMcpClient(ctx.mcpServerUrl); log.info("» fetching MCP tool list..."); const tools = await getOllamaTools(mcpClient); log.info(`» ${tools.length} tools available`); const messages: Message[] = [ { role: "user", content: ctx.instructions.full, }, ]; let iterations = 0; let pendingModeNudge = false; while (iterations < MAX_ITERATIONS) { iterations++; log.info(`» Ollama turn ${iterations}/${MAX_ITERATIONS}...`); let response: Awaited>; try { response = await ollama.chat({ model, messages, tools, options: { think: false, } as Record, }); } catch (err) { const lastError = err instanceof Error ? err.message : String(err); log.error(`Ollama error: ${lastError}`); return { success: false, error: `Ollama request failed: ${lastError}` }; } const assistantMessage = response.message; messages.push(assistantMessage); const toolCalls: ToolCall[] | undefined = assistantMessage.tool_calls; if (!toolCalls || toolCalls.length === 0) { // If we just gave the model a nudge after select_mode and it still won't // call tools, it's genuinely done (or stuck) — exit cleanly. if (pendingModeNudge) { log.info("» agent finished after mode nudge (no tool calls)"); } else { log.info("» agent finished (no tool calls)"); } return { success: true, output: assistantMessage.content || undefined, }; } pendingModeNudge = false; const calledSelectMode = toolCalls.some( (tc) => tc.function.name === "select_mode", ); for (const toolCall of toolCalls) { const toolName = toolCall.function.name; const toolArgs = toolCall.function.arguments; log.info(`» calling tool: ${toolName}`); log.debug(` args: ${JSON.stringify(toolArgs)}`); if (ctx.onToolUse) { ctx.onToolUse({ toolName, input: toolArgs }); } const result = await callMcpTool( mcpClient, toolName, toolArgs as Record, ); log.debug(` result: ${result.slice(0, 200)}`); messages.push({ role: "tool", content: result, }); } // After the FIRST select_mode call, nudge the model to act on the guidance. // Only nudge once — repeated nudging causes a loop where the model keeps // re-calling select_mode instead of executing the workflow. if (calledSelectMode && !pendingModeNudge) { pendingModeNudge = true; messages.push({ role: "user", content: "Good. You have selected a mode and received the workflow. " + "Do NOT call select_mode again. " + "Call the next tool to execute the workflow now (e.g. checkout_pr, get_issue, create_pull_request_review). " + "Start immediately.", }); } } log.warning(`» agent hit max iterations (${MAX_ITERATIONS})`); return { success: false, error: `Agent exceeded maximum iterations (${MAX_ITERATIONS})`, }; } export const ollamaAgent = agent({ name: "ollama", run: async (ctx: AgentRunContext): Promise => { return runOllamaLoop(ctx); }, });