WFDASH-WEBAPP/src/lib/server/giellie-agent.ts
Dave 7562a8be27 fix(giellie): args-aware tool policy, deny quotes.re_run_ai, harden bridge and agent
- deny quotes.re_run_ai (wipes every quote item); orders.cancel and
  delivery_notes.cancel/combine always ask; orders.archive is a read tool
- classifyToolCall escalates writes that touch email/contact fields to confirm,
  with a reason stored on the pending tool use
- system prompt: real cancel tools, SAST date
- bridge rejects @collection./@request. filter references and path-shaped ids
- agent: blank-text responses become the placeholder, duplicate pending ids
  answered once, clearer "result unavailable" wording
- catalogue test checks the policy against the real MCP tool list

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 00:36:54 +02:00

254 lines
8 KiB
TypeScript

/**
* 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,
classifyToolCall,
fromModelToolName,
toModelToolName,
truncateForModel,
type ModelTool,
} from "./giellie-tool-policy";
import {
RESULT_UNAVAILABLE_TEXT,
type Block,
type PendingAction,
type PendingToolUse,
type 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();
// An empty assistant message would make every later API call 400.
// Blank text-only responses are rejected the same way, so treat them alike.
const blank = final.content.every(
(b) => b.type === "text" && String(b.text ?? "").trim() === "",
);
const content: Block[] = blank ? [{ type: "text", text: "(no response)" }] : final.content;
messages.push({ role: "assistant", content, ts: now() });
const toolUses = 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";
}
// Truncated output may carry truncated tool inputs: run nothing.
if (final.stop_reason === "max_tokens") {
messages.push({
role: "user",
content: toolUses.map((tu) =>
resultBlock(
tu.id,
"Your output was cut off before the tool call completed, so it was not executed. Retry with a smaller step.",
true,
),
),
ts: now(),
});
await deps.checkpoint(messages, null);
continue;
}
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, reason } = classifyToolCall(name, input);
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",
...(cls === "confirm" && reason ? { confirmReason: reason } : {}),
});
}
}
if (deferred.length > 0) {
const pending: PendingAction = {
tool_uses: deferred,
completed,
order: toolUses.map((t) => t.id),
};
// Persist before showing the card so the UI never shows an unsaved one.
await deps.checkpoint(messages, pending);
deps.emit({ type: "approval_required", pending });
return "approval_required";
}
messages.push({ role: "user", content: completed, ts: now() });
await deps.checkpoint(messages, null);
}
const note = `Stopped after ${maxTurns} steps without finishing. Say "continue" and I'll pick up where I left off.`;
messages.push({ role: "assistant", content: [{ type: "text", text: note }], ts: now() });
deps.emit({ type: "text", delta: note });
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 known = new Set(deps.tools.map((t) => t.name));
// A tampered duplicate id must not be answered twice.
const order = Array.from(new Set(pending.order));
const inOrder = new Set(order);
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) {
if (!inOrder.has(tu.id)) continue; // never execute an unrecorded tool_use
if (classifyTool(tu.name) === "denied" || !known.has(toModelToolName(tu.name))) {
byId.set(tu.id, resultBlock(tu.id, `Tool "${tu.name}" is not available to you.`, true));
continue;
}
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: order.map((id) => byId.get(id) ?? resultBlock(id, RESULT_UNAVAILABLE_TEXT, true)),
ts: now(),
});
await deps.checkpoint(messages, null);
}