feat: initial Lam Agent extension (#1)
This commit was merged in pull request #1.
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user