feat: initial Lam Agent extension (#1)

This commit was merged in pull request #1.
This commit is contained in:
2026-10-03 20:32:40 +00:00
parent 1d39aabb50
commit 8b507e7953
36 changed files with 7136 additions and 2 deletions
+134
View File
@@ -0,0 +1,134 @@
import { MAX_TOOL_ITERATIONS } from "./constants";
import { streamChat } from "./ollama";
import { executeTool, toolLabel, type ToolContext } from "./tools";
import type { ApiMessage, ToolCall, ToolCallRecord, ToolDefinition } from "./types";
/**
* The tool-calling loop.
*
* Kept free of any React or Vicinae imports so the whole thing can be driven
* from a plain unit test with a stubbed `fetch`.
*/
export interface AgentEvents {
onContentDelta?: (delta: string) => void;
onThinkingDelta?: (delta: string) => void;
onToolCall?: (record: ToolCallRecord) => void;
/** A short line for the UI's activity indicator, e.g. "Searching the web". */
onStatus?: (status: string) => void;
}
export interface AgentOptions {
host: string;
apiKey: string;
model: string;
/** Omit or pass "off" to leave reasoning disabled. */
think?: string;
messages: ApiMessage[];
tools: ToolDefinition[];
toolContext: Omit<ToolContext, "signal" | "now"> & { now?: Date };
signal?: AbortSignal;
events?: AgentEvents;
}
export interface AgentResult {
content: string;
thinking: string;
toolCalls: ToolCallRecord[];
}
/**
* Picks the authoritative tool-call list out of a stream.
*
* Ollama sends tool calls complete in the final streamed message rather than
* dribbling them out across chunks the way OpenAI does, so the last non-empty
* array wins. Exported because the assumption is worth pinning down in a test:
* if Ollama ever starts streaming partial calls, this is the one place to fix.
*/
export function pickToolCalls(
previous: ToolCall[] | undefined,
incoming: ToolCall[] | undefined,
): ToolCall[] | undefined {
return incoming && incoming.length > 0 ? incoming : previous;
}
function normalizeToolCall(call: ToolCall): { name: string; arguments: Record<string, unknown> } {
const name = typeof call.function?.name === "string" ? call.function.name : "";
const rawArgs = call.function?.arguments;
const args = rawArgs && typeof rawArgs === "object" && !Array.isArray(rawArgs) ? rawArgs : {};
return { name, arguments: args as Record<string, unknown> };
}
export async function runAgent(options: AgentOptions): Promise<AgentResult> {
const conversation: ApiMessage[] = [...options.messages];
const toolCalls: ToolCallRecord[] = [];
let thinking = "";
for (let iteration = 0; iteration < MAX_TOOL_ITERATIONS; iteration++) {
let content = "";
let streamedThinking = "";
let pending: ToolCall[] | undefined;
for await (const chunk of streamChat({
host: options.host,
apiKey: options.apiKey,
model: options.model,
messages: conversation,
tools: options.tools.length > 0 ? options.tools : undefined,
think: options.think,
signal: options.signal,
})) {
if (chunk.thinking) {
streamedThinking += chunk.thinking;
options.events?.onThinkingDelta?.(chunk.thinking);
}
if (chunk.content) {
content += chunk.content;
options.events?.onContentDelta?.(chunk.content);
}
pending = pickToolCalls(pending, chunk.toolCalls);
}
thinking += streamedThinking;
// No tool calls means this was the model's answer. Earlier iterations only
// produced a preamble, which is intentionally discarded rather than shown
// as a half-answer.
if (!pending || pending.length === 0) {
return { content, thinking, toolCalls };
}
conversation.push({ role: "assistant", content, tool_calls: pending });
for (const rawCall of pending) {
const { name, arguments: args } = normalizeToolCall(rawCall);
if (!name) {
conversation.push({ role: "tool", content: "Malformed tool call: missing function name.", tool_name: "unknown" });
toolCalls.push({ name: "unknown", arguments: {}, result: "Malformed tool call.", isError: true });
continue;
}
options.events?.onStatus?.(toolLabel(name));
const result = await executeTool(name, args, {
host: options.toolContext.host,
apiKey: options.toolContext.apiKey,
now: options.toolContext.now,
signal: options.signal,
});
const record: ToolCallRecord = { name, arguments: args, result: result.content, isError: result.isError };
toolCalls.push(record);
options.events?.onToolCall?.(record);
conversation.push({ role: "tool", content: result.content, tool_name: name });
}
}
return {
content: `_Stopped after ${MAX_TOOL_ITERATIONS} rounds of tool calls without a final answer._`,
thinking,
toolCalls,
};
}