- 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>
254 lines
8 KiB
TypeScript
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);
|
|
}
|