Files
shockbot/agents/ollama.ts
T

200 lines
6.0 KiB
TypeScript

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<string, unknown>;
};
}
async function buildMcpClient(mcpServerUrl: string): Promise<Client> {
const client = new Client(
{ name: "shockbot-agent", version: "0.1.0" },
{ capabilities: {} },
);
const transport = new StreamableHTTPClientTransport(new URL(mcpServerUrl));
// @ts-expect-error — StreamableHTTPClientTransport.sessionId is string|undefined but Transport
// expects string; this is an @modelcontextprotocol/sdk internal type mismatch, not our bug.
await client.connect(transport);
return client;
}
async function getOllamaTools(mcpClient: Client): Promise<OllamaTool[]> {
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<string, unknown>) ?? {
type: "object",
properties: {},
},
},
}));
}
async function callMcpTool(
mcpClient: Client,
toolName: string,
args: Record<string, unknown>,
): Promise<string> {
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<AgentResult> {
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<ReturnType<typeof ollama.chat>>;
try {
response = await ollama.chat({
model,
messages,
tools,
options: {
think: false,
} as Record<string, unknown>,
});
} 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<string, unknown>,
);
log.debug(` result: ${result.slice(0, 200)}`);
messages.push({
role: "tool",
content: result,
});
}
// After select_mode, explicitly tell the model to act on the returned guidance.
// Without this nudge, smaller models tend to treat the mode instructions as
// informational and stop rather than executing the workflow steps.
if (calledSelectMode) {
pendingModeNudge = true;
messages.push({
role: "user",
content:
"You have selected a mode and received your workflow instructions. " +
"Now execute the first step of that workflow immediately by calling the appropriate tool. " +
"Do not describe what you will do — just call the tool.",
});
}
}
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<AgentResult> => {
return runOllamaLoop(ctx);
},
});