feat(giellie): agent loop with approval pauses
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
b35159d859
commit
14ab023d71
2 changed files with 426 additions and 0 deletions
224
src/lib/server/giellie-agent.test.ts
Normal file
224
src/lib/server/giellie-agent.test.ts
Normal file
|
|
@ -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<Record<string, unknown>> } {
|
||||
let i = 0;
|
||||
const calls: Array<Record<string, unknown>> = [];
|
||||
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<string, unknown> = {}): Block => ({
|
||||
type: "tool_use",
|
||||
id,
|
||||
name,
|
||||
input,
|
||||
});
|
||||
|
||||
function setup(turns: Block[][], over: Partial<AgentDeps> = {}) {
|
||||
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<GielieEvent, { type: "approval_required" }>;
|
||||
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<GielieEvent, { type: "approval_required" }>;
|
||||
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<GielieEvent, { type: "approval_required" }>;
|
||||
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")]);
|
||||
});
|
||||
});
|
||||
202
src/lib/server/giellie-agent.ts
Normal file
202
src/lib/server/giellie-agent.ts
Normal file
|
|
@ -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<ModelResponse>;
|
||||
}
|
||||
export interface ModelClient {
|
||||
messages: { stream(params: Record<string, unknown>): 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<string, unknown>): Promise<ToolResult>;
|
||||
emit(event: GielieEvent): void;
|
||||
checkpoint(messages: StoredMessage[], pending: PendingAction | null): Promise<void>;
|
||||
/** 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<string, unknown>,
|
||||
deps: AgentDeps,
|
||||
): Promise<Block> {
|
||||
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<AgentOutcome> {
|
||||
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<string, unknown> }
|
||||
>;
|
||||
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<string, unknown>;
|
||||
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<void> {
|
||||
const byId = new Map<string, Block>();
|
||||
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(),
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue