diff --git a/src/lib/server/giellie-agent.test.ts b/src/lib/server/giellie-agent.test.ts new file mode 100644 index 0000000..10e51d6 --- /dev/null +++ b/src/lib/server/giellie-agent.test.ts @@ -0,0 +1,224 @@ +import { describe, it, expect, vi } from "vitest"; +import { + runAgent, + resolvePending, + toModelMessages, + type AgentDeps, + type ModelClient, + type ModelStream, +} from "./giellie-agent"; +import type { Block, StoredMessage, PendingAction } from "@/lib/giellie-types"; +import type { GielieEvent } from "@/lib/giellie-sse"; + +const tool = (name: string) => ({ name, description: "", input_schema: { type: "object", properties: {} } }); +const TOOLS = [ + tool("orders__list"), + tool("orders__update"), + tool("quotes__send_email"), + tool("companies__delete"), +]; + +function fakeClient(turns: Block[][]): ModelClient & { calls: Array> } { + let i = 0; + const calls: Array> = []; + return { + calls, + messages: { + stream: (params) => { + calls.push(params); + const content = turns[i++]; + if (!content) throw new Error("model called more times than scripted"); + const s: ModelStream = { + on(ev, cb) { + if (ev === "text") for (const b of content) if (b.type === "text") cb(String(b.text)); + return s; + }, + finalMessage: async () => ({ content }), + }; + return s; + }, + }, + }; +} + +const text = (t: string): Block => ({ type: "text", text: t }); +const use = (id: string, name: string, input: Record = {}): Block => ({ + type: "tool_use", + id, + name, + input, +}); + +function setup(turns: Block[][], over: Partial = {}) { + const client = fakeClient(turns); + const events: GielieEvent[] = []; + const callTool = vi.fn(async () => ({ text: '{"ok":true}', isError: false })); + const checkpoint = vi.fn(async () => {}); + const deps: AgentDeps = { + client, + model: "m", + maxTokens: 100, + system: "sys", + tools: TOOLS, + callTool, + emit: (e) => events.push(e), + checkpoint, + trust: false, + ...over, + }; + const messages: StoredMessage[] = [ + { role: "user", content: [text("hi")], ts: "t" }, + ]; + return { client, events, callTool, checkpoint, deps, messages }; +} + +describe("runAgent", () => { + it("answers directly and streams text", async () => { + const s = setup([[text("hello")]]); + const out = await runAgent(s.messages, s.deps); + expect(out).toBe("done"); + expect(s.events).toContainEqual({ type: "text", delta: "hello" }); + expect(s.messages.at(-1)?.role).toBe("assistant"); + expect(s.checkpoint).toHaveBeenCalledTimes(1); + }); + + it("runs read tools immediately and loops", async () => { + const s = setup([[use("t1", "orders__list", { perPage: 5 })], [text("done")]]); + const out = await runAgent(s.messages, s.deps); + expect(out).toBe("done"); + expect(s.callTool).toHaveBeenCalledWith("orders.list", { perPage: 5 }); + expect(s.client.calls).toHaveLength(2); + const userResult = s.messages.find( + (m) => m.role === "user" && m.content[0].type === "tool_result", + ); + expect(userResult?.content[0]).toMatchObject({ tool_use_id: "t1", content: '{"ok":true}' }); + expect(s.events.map((e) => e.type)).toEqual(expect.arrayContaining(["tool_start", "tool_result"])); + }); + + it("pauses for approval on writes without executing them", async () => { + const s = setup([[text("ok"), use("t1", "orders__update", { id: "a" })]]); + const out = await runAgent(s.messages, s.deps); + expect(out).toBe("approval_required"); + expect(s.callTool).not.toHaveBeenCalled(); + const ev = s.events.find((e) => e.type === "approval_required") as Extract; + expect(ev.pending.tool_uses).toEqual([ + { id: "t1", name: "orders.update", input: { id: "a" }, confirmAlways: false }, + ]); + expect(ev.pending.order).toEqual(["t1"]); + expect(s.checkpoint).toHaveBeenLastCalledWith(s.messages, ev.pending); + }); + + it("keeps read results in pending.completed when a write pauses the turn", async () => { + const s = setup([[use("r1", "orders__list"), use("w1", "orders__update")]]); + await runAgent(s.messages, s.deps); + const ev = s.events.find((e) => e.type === "approval_required") as Extract; + expect(ev.pending.completed).toHaveLength(1); + expect(ev.pending.completed[0]).toMatchObject({ tool_use_id: "r1" }); + expect(ev.pending.order).toEqual(["r1", "w1"]); + }); + + it("auto-runs ordinary writes when the chat is trusted", async () => { + const s = setup([[use("t1", "orders__update", { id: "a" })], [text("done")]], { trust: true }); + const out = await runAgent(s.messages, s.deps); + expect(out).toBe("done"); + expect(s.callTool).toHaveBeenCalledWith("orders.update", { id: "a" }); + }); + + it("still pauses for email sends when trusted", async () => { + const s = setup([[use("t1", "quotes__send_email", { id: "q" })]], { trust: true }); + const out = await runAgent(s.messages, s.deps); + expect(out).toBe("approval_required"); + expect(s.callTool).not.toHaveBeenCalled(); + const ev = s.events.find((e) => e.type === "approval_required") as Extract; + expect(ev.pending.tool_uses[0].confirmAlways).toBe(true); + }); + + it("refuses denied tools without calling them", async () => { + const s = setup([[use("t1", "companies__delete", { id: "c" })], [text("ok")]], { trust: true }); + await runAgent(s.messages, s.deps); + expect(s.callTool).not.toHaveBeenCalled(); + const res = s.messages.find((m) => m.content[0].type === "tool_result"); + expect(res?.content[0]).toMatchObject({ is_error: true }); + }); + + it("errors on tools that are not in the catalogue", async () => { + const s = setup([[use("t1", "made_up__tool")], [text("ok")]]); + await runAgent(s.messages, s.deps); + expect(s.callTool).not.toHaveBeenCalled(); + const res = s.messages.find((m) => m.content[0].type === "tool_result"); + expect(res?.content[0]).toMatchObject({ is_error: true }); + }); + + it("turns a throwing tool into an error result", async () => { + const s = setup([[use("t1", "orders__list")], [text("ok")]]); + s.callTool.mockRejectedValueOnce(new Error("boom")); + await runAgent(s.messages, s.deps); + const res = s.messages.find((m) => m.content[0].type === "tool_result"); + expect(res?.content[0]).toMatchObject({ is_error: true }); + expect(String((res?.content[0] as unknown as { content: string }).content)).toContain("boom"); + }); + + it("stops at maxTurns", async () => { + const s = setup([[use("t1", "orders__list")], [use("t2", "orders__list")]], { maxTurns: 2 }); + expect(await runAgent(s.messages, s.deps)).toBe("max_turns"); + }); + + it("does not checkpoint a dangling tool_use when the model call fails", async () => { + const s = setup([[use("t1", "orders__list")]]); + // Second model call is unscripted -> throws. + await expect(runAgent(s.messages, s.deps)).rejects.toThrow(); + for (const call of s.checkpoint.mock.calls as unknown as Array<[StoredMessage[], unknown]>) { + const last = call[0].at(-1)!; + expect(last.content.some((b) => b.type === "tool_use") && last.role === "assistant").toBe(false); + } + }); +}); + +describe("resolvePending", () => { + const pending: PendingAction = { + tool_uses: [{ id: "w1", name: "orders.update", input: { id: "a" }, confirmAlways: false }], + completed: [{ type: "tool_result", tool_use_id: "r1", content: "read-result" }], + order: ["r1", "w1"], + }; + + it("executes approved tools and merges results in original order", async () => { + const s = setup([]); + await resolvePending(s.messages, pending, true, s.deps); + expect(s.callTool).toHaveBeenCalledWith("orders.update", { id: "a" }); + const last = s.messages.at(-1)!; + expect(last.role).toBe("user"); + expect(last.content.map((b) => b.tool_use_id)).toEqual(["r1", "w1"]); + }); + + it("returns a rejection result without executing", async () => { + const s = setup([]); + await resolvePending(s.messages, pending, false, s.deps); + expect(s.callTool).not.toHaveBeenCalled(); + const w = s.messages.at(-1)!.content[1]; + expect(w).toMatchObject({ tool_use_id: "w1", is_error: true }); + expect(String(w.content)).toContain("rejected"); + }); +}); + +describe("toModelMessages", () => { + it("maps attachment chips to text and drops ts", () => { + const out = toModelMessages([ + { role: "user", ts: "t", content: [{ type: "attachment", filename: "a.jpg", kind: "image" }, text("x")] }, + ]); + expect(out[0]).toEqual({ + role: "user", + content: [{ type: "text", text: "[attached image: a.jpg]" }, text("x")], + }); + }); + it("substitutes live blocks at the live index (also mapping chips)", () => { + const msgs: StoredMessage[] = [ + { role: "user", ts: "t", content: [text("stored")] }, + ]; + const img: Block = { type: "image", source: { type: "base64", media_type: "image/png", data: "AA" } }; + const out = toModelMessages(msgs, { + index: 0, + blocks: [img, { type: "attachment", filename: "p.png", kind: "image" }, text("live")], + }); + expect(out[0].content).toEqual([img, { type: "text", text: "[attached image: p.png]" }, text("live")]); + }); +}); diff --git a/src/lib/server/giellie-agent.ts b/src/lib/server/giellie-agent.ts new file mode 100644 index 0000000..afc6b6c --- /dev/null +++ b/src/lib/server/giellie-agent.ts @@ -0,0 +1,202 @@ +/** + * Giellie agent loop: model <-> tools, with approval pauses. + * + * Pure with respect to I/O — the model client, tool caller, event sink and + * persistence are injected, so the loop is unit-tested with scripted fakes. + * + * Invariants + * • The full assistant content (including MiniMax `thinking` blocks) is + * stored, so the model sees its own reasoning on the next turn. + * • `checkpoint` is only called at consistent states (after a complete + * turn, or at an approval pause), so a failure never persists a + * tool_use without its tool_result. + * • Denied / unknown tools never reach `callTool`. + */ +import { + classifyTool, + fromModelToolName, + truncateForModel, + type ModelTool, +} from "./giellie-tool-policy"; +import type { Block, PendingAction, PendingToolUse, StoredMessage } from "@/lib/giellie-types"; +import type { GielieEvent } from "@/lib/giellie-sse"; + +export interface ModelResponse { + content: Block[]; + stop_reason?: string | null; +} +export interface ModelStream { + on(event: "text", cb: (delta: string) => void): ModelStream; + finalMessage(): Promise; +} +export interface ModelClient { + messages: { stream(params: Record): ModelStream }; +} +export interface ToolResult { + text: string; + isError: boolean; +} + +export interface AgentDeps { + client: ModelClient; + model: string; + maxTokens: number; + system: string; + tools: ModelTool[]; + callTool(name: string, input: Record): Promise; + emit(event: GielieEvent): void; + checkpoint(messages: StoredMessage[], pending: PendingAction | null): Promise; + /** Auto-approve ordinary writes (never email/PO sends). */ + trust: boolean; + maxTurns?: number; +} + +export interface LiveTurn { + index: number; + blocks: Block[]; +} +export type AgentOutcome = "done" | "approval_required" | "max_turns"; + +const DEFAULT_MAX_TURNS = 25; + +const now = () => new Date().toISOString(); + +function mapBlocks(blocks: Block[]): Block[] { + return blocks.map((b) => + b.type === "attachment" + ? { type: "text", text: `[attached ${String(b.kind)}: ${String(b.filename)}]` } + : b, + ); +} + +export function toModelMessages( + messages: StoredMessage[], + live?: LiveTurn, +): Array<{ role: string; content: Block[] }> { + return messages.map((m, i) => ({ + role: m.role, + content: mapBlocks(live && live.index === i ? live.blocks : m.content), + })); +} + +function resultBlock(toolUseId: string, content: string, isError: boolean): Block { + return { + type: "tool_result", + tool_use_id: toolUseId, + content, + ...(isError ? { is_error: true } : {}), + }; +} + +async function execute( + id: string, + name: string, + input: Record, + deps: AgentDeps, +): Promise { + deps.emit({ type: "tool_start", id, name, input }); + let result: ToolResult; + try { + result = await deps.callTool(name, input); + } catch (e) { + result = { text: `Error: ${e instanceof Error ? e.message : String(e)}`, isError: true }; + } + deps.emit({ + type: "tool_result", + id, + name, + isError: result.isError, + preview: result.text.slice(0, 300), + }); + return resultBlock(id, truncateForModel(result.text), result.isError); +} + +export async function runAgent( + messages: StoredMessage[], + deps: AgentDeps, + live?: LiveTurn, +): Promise { + const maxTurns = deps.maxTurns ?? DEFAULT_MAX_TURNS; + const known = new Set(deps.tools.map((t) => t.name)); + + for (let turn = 0; turn < maxTurns; turn++) { + const stream = deps.client.messages.stream({ + model: deps.model, + max_tokens: deps.maxTokens, + system: deps.system, + tools: deps.tools, + messages: toModelMessages(messages, live), + }); + stream.on("text", (delta) => deps.emit({ type: "text", delta })); + const final = await stream.finalMessage(); + messages.push({ role: "assistant", content: final.content, ts: now() }); + + const toolUses = final.content.filter((b) => b.type === "tool_use") as Array< + Block & { id: string; name: string; input: Record } + >; + if (toolUses.length === 0) { + await deps.checkpoint(messages, null); + return "done"; + } + + const completed: Block[] = []; + const deferred: PendingToolUse[] = []; + for (const tu of toolUses) { + const name = fromModelToolName(tu.name); + const input = (tu.input ?? {}) as Record; + const cls = classifyTool(name); + if (cls === "denied") { + completed.push(resultBlock(tu.id, `Tool "${name}" is not available to you.`, true)); + } else if (!known.has(tu.name)) { + completed.push(resultBlock(tu.id, `Unknown tool "${name}".`, true)); + } else if (cls === "read" || (cls === "write" && deps.trust)) { + completed.push(await execute(tu.id, name, input, deps)); + } else { + deferred.push({ id: tu.id, name, input, confirmAlways: cls === "confirm" }); + } + } + + if (deferred.length > 0) { + const pending: PendingAction = { + tool_uses: deferred, + completed, + order: toolUses.map((t) => t.id), + }; + deps.emit({ type: "approval_required", pending }); + await deps.checkpoint(messages, pending); + return "approval_required"; + } + + messages.push({ role: "user", content: completed, ts: now() }); + await deps.checkpoint(messages, null); + } + return "max_turns"; +} + +/** Apply the user's decision to a paused turn: append the tool_result message. */ +export async function resolvePending( + messages: StoredMessage[], + pending: PendingAction, + approve: boolean, + deps: AgentDeps, +): Promise { + const byId = new Map(); + for (const b of pending.completed) byId.set(String(b.tool_use_id), b); + for (const tu of pending.tool_uses) { + byId.set( + tu.id, + approve + ? await execute(tu.id, tu.name, tu.input, deps) + : resultBlock( + tu.id, + "The user rejected this action. Do not retry it; ask what they would like instead.", + true, + ), + ); + } + messages.push({ + role: "user", + content: pending.order.map((id) => byId.get(id)!).filter(Boolean), + ts: now(), + }); +}