Files
AgentCompany/tests/eval/tool-loop-replay.ts
T

259 lines
6.6 KiB
TypeScript

import {
Agent,
type AgentMessage,
type AgentTool,
} from "@mariozechner/pi-agent-core";
import {
Type,
type Api,
type AssistantMessage,
type Model,
type ToolCall,
type UserMessage,
} from "@mariozechner/pi-ai";
import { createReplayStreamFn } from "./replay-client.js";
import type {
AssistantTurn,
FixtureFile,
TokenLedger,
ToolResultTurn,
TraceTurn,
UserTurn,
} from "./types.js";
const REPLAY_TOOL_PARAMETERS = Type.Record(Type.String(), Type.Any());
const ZERO_COST = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
};
function createReplayModel(): Model<Api> {
return {
id: "eval-replay",
name: "Eval Replay",
api: "eval-replay",
provider: "eval-replay",
baseUrl: "https://example.invalid",
reasoning: false,
input: ["text"],
cost: ZERO_COST,
contextWindow: 128_000,
maxTokens: 8192,
};
}
function collectAssistantToolCalls(fixture: FixtureFile): ToolCall[] {
const calls: ToolCall[] = [];
for (const turn of fixture.turns) {
if (turn.role !== "assistant") continue;
for (const block of turn.content) {
if (block.type === "toolCall") calls.push(block);
}
}
return calls;
}
function validateToolLoopShape(fixture: FixtureFile): void {
const pending = new Set<string>();
const seen = new Set<string>();
for (const turn of fixture.turns) {
if (turn.role === "assistant") {
if (pending.size > 0) {
throw new Error(
`eval replay: fixture "${fixture.meta.fixtureId}" has assistant turn ${turn.turnIndex} before tool result(s): ${Array.from(pending).join(", ")}`,
);
}
for (const block of turn.content) {
if (block.type !== "toolCall") continue;
if (seen.has(block.id)) {
throw new Error(
`eval replay: fixture "${fixture.meta.fixtureId}" repeats tool call id "${block.id}"`,
);
}
seen.add(block.id);
pending.add(block.id);
}
continue;
}
if (turn.role === "toolResult") {
if (!pending.has(turn.toolUseId)) {
throw new Error(
`eval replay: fixture "${fixture.meta.fixtureId}" has tool result "${turn.toolUseId}" without a pending tool call`,
);
}
pending.delete(turn.toolUseId);
continue;
}
if (turn.role === "user" && pending.size > 0) {
throw new Error(
`eval replay: fixture "${fixture.meta.fixtureId}" has user turn ${turn.turnIndex} before tool result(s): ${Array.from(pending).join(", ")}`,
);
}
}
if (pending.size > 0) {
throw new Error(
`eval replay: fixture "${fixture.meta.fixtureId}" ended before tool result(s): ${Array.from(pending).join(", ")}`,
);
}
}
function collectRecordedToolResults(
fixture: FixtureFile,
): Map<string, ToolResultTurn> {
const results = new Map<string, ToolResultTurn>();
for (const turn of fixture.turns) {
if (turn.role !== "toolResult") continue;
if (results.has(turn.toolUseId)) {
throw new Error(
`eval replay: fixture "${fixture.meta.fixtureId}" repeats tool result for "${turn.toolUseId}"`,
);
}
results.set(turn.toolUseId, turn);
}
return results;
}
function createReplayTools(fixture: FixtureFile): AgentTool[] {
const results = collectRecordedToolResults(fixture);
const toolNames = Array.from(
new Set(collectAssistantToolCalls(fixture).map((call) => call.name)),
).sort();
return toolNames.map((name) => ({
name,
label: `eval replay ${name}`,
description:
"Replay a recorded eval tool result. The fixture owns the content and the call id is the lookup key.",
parameters: REPLAY_TOOL_PARAMETERS,
async execute(toolCallId, params) {
const recorded = results.get(toolCallId);
if (!recorded) {
throw new Error(
`eval replay: fixture "${fixture.meta.fixtureId}" has no recorded tool result for "${toolCallId}"`,
);
}
return {
content: recorded.content,
details: {
fixtureId: fixture.meta.fixtureId,
toolCallId,
toolName: name,
arguments: params,
},
};
},
executionMode: "sequential",
}));
}
function asUserMessage(turn: UserTurn): UserMessage {
return {
role: "user",
content: turn.content,
timestamp: Date.now(),
};
}
function usageToLedger(message: AssistantMessage): TokenLedger {
return {
input: message.usage.input,
cache_read: message.usage.cacheRead,
cache_creation: message.usage.cacheWrite,
output: message.usage.output,
};
}
function agentMessageToTraceTurn(
message: AgentMessage,
turnIndex: number,
): TraceTurn | null {
if (message.role === "user") {
const content = typeof message.content === "string"
? [{ type: "text" as const, text: message.content }]
: message.content;
return {
type: "turn",
turnIndex,
role: "user",
content,
};
}
if (message.role === "assistant") {
return {
type: "turn",
turnIndex,
role: "assistant",
content: message.content,
tokenLedger: usageToLedger(message),
} satisfies AssistantTurn;
}
if (message.role === "toolResult") {
return {
type: "turn",
turnIndex,
role: "toolResult",
toolUseId: message.toolCallId,
content: message.content,
};
}
return null;
}
function collectUserTurns(fixture: FixtureFile): UserTurn[] {
return fixture.turns.filter((turn): turn is UserTurn => turn.role === "user");
}
export async function replayFixtureThroughAgent(
fixture: FixtureFile,
systemPrompt: string,
): Promise<TraceTurn[]> {
validateToolLoopShape(fixture);
const userTurns = collectUserTurns(fixture);
const firstUserTurn = userTurns[0];
if (!firstUserTurn || fixture.turns[0]?.role !== "user") {
throw new Error(
`eval replay: fixture "${fixture.meta.fixtureId}" must start with a user turn`,
);
}
const agent = new Agent({
initialState: {
systemPrompt,
model: createReplayModel(),
tools: createReplayTools(fixture),
messages: [],
thinkingLevel: "off",
},
sessionId: `eval:${fixture.meta.fixtureId}`,
streamFn: createReplayStreamFn(fixture),
toolExecution: "sequential",
});
for (const followUp of userTurns.slice(1)) {
agent.followUp(asUserMessage(followUp));
}
await agent.prompt(asUserMessage(firstUserTurn));
if (agent.state.errorMessage) {
throw new Error(
`eval replay: fixture "${fixture.meta.fixtureId}" failed inside pi Agent loop: ${agent.state.errorMessage}`,
);
}
return agent.state.messages
.map((message, idx) => agentMessageToTraceTurn(message, idx))
.filter((turn): turn is TraceTurn => turn !== null);
}