add usage info to branch summary, compaction and tool result entries (#6671)
* add usage info to branch summary entries * add usage to compaction entries * allow custom tools to report llm usage in tool results * allow observing and patching usage in tool_result hooks * agent-harness: save usage in entries for compaction, branch summaries and tool results
This commit is contained in:
@@ -737,6 +737,7 @@ async function finalizeExecutedToolCall(
|
||||
...result,
|
||||
content: afterResult.content ?? result.content,
|
||||
details: afterResult.details ?? result.details,
|
||||
usage: afterResult.usage ?? result.usage,
|
||||
terminate: afterResult.terminate ?? result.terminate,
|
||||
};
|
||||
isError = afterResult.isError ?? isError;
|
||||
@@ -780,6 +781,7 @@ function createToolResultMessage(finalized: FinalizedToolCallOutcome): ToolResul
|
||||
// so the null never enters session history or provider payloads.
|
||||
content: finalized.result.content ?? [],
|
||||
details: finalized.result.details,
|
||||
usage: finalized.result.usage,
|
||||
...(finalized.result.addedToolNames?.length ? { addedToolNames: finalized.result.addedToolNames } : {}),
|
||||
isError: finalized.isError,
|
||||
timestamp: Date.now(),
|
||||
|
||||
@@ -32,6 +32,7 @@ import type {
|
||||
AgentHarnessResources,
|
||||
AgentHarnessStreamOptions,
|
||||
AgentHarnessStreamOptionsPatch,
|
||||
CompactResult,
|
||||
ExecutionEnv,
|
||||
NavigateTreeResult,
|
||||
PendingSessionWrite,
|
||||
@@ -434,9 +435,16 @@ export class AgentHarness<
|
||||
content: result.content,
|
||||
details: result.details,
|
||||
isError,
|
||||
usage: result.usage,
|
||||
});
|
||||
return patch
|
||||
? { content: patch.content, details: patch.details, isError: patch.isError, terminate: patch.terminate }
|
||||
? {
|
||||
content: patch.content,
|
||||
details: patch.details,
|
||||
isError: patch.isError,
|
||||
usage: patch.usage,
|
||||
terminate: patch.terminate,
|
||||
}
|
||||
: undefined;
|
||||
},
|
||||
prepareNextTurn: async () => {
|
||||
@@ -690,9 +698,7 @@ export class AgentHarness<
|
||||
}
|
||||
}
|
||||
|
||||
async compact(
|
||||
customInstructions?: string,
|
||||
): Promise<{ summary: string; firstKeptEntryId: string; tokensBefore: number; details?: unknown }> {
|
||||
async compact(customInstructions?: string): Promise<CompactResult> {
|
||||
if (this.phase !== "idle") throw new AgentHarnessError("busy", "compact() requires idle harness");
|
||||
this.phase = "compaction";
|
||||
try {
|
||||
@@ -723,6 +729,7 @@ export class AgentHarness<
|
||||
result.tokensBefore,
|
||||
result.details,
|
||||
provided !== undefined,
|
||||
result.usage,
|
||||
);
|
||||
const entry = await this.session.getEntry(entryId);
|
||||
if (entry?.type === "compaction") {
|
||||
@@ -764,6 +771,7 @@ export class AgentHarness<
|
||||
let summaryEntry: NavigateTreeResult["summaryEntry"];
|
||||
let summaryText: string | undefined = hookResult?.summary?.summary;
|
||||
let summaryDetails: unknown = hookResult?.summary?.details;
|
||||
let summaryUsage = hookResult?.summary?.usage;
|
||||
if (!summaryText && options?.summarize && entries.length > 0) {
|
||||
const model = this.model;
|
||||
if (!model) throw new AgentHarnessError("invalid_state", "No model set for branch summary");
|
||||
@@ -779,6 +787,7 @@ export class AgentHarness<
|
||||
throw new AgentHarnessError("branch_summary", branchSummary.error.message, branchSummary.error);
|
||||
}
|
||||
summaryText = branchSummary.value.summary;
|
||||
summaryUsage = branchSummary.value.usage;
|
||||
summaryDetails = {
|
||||
readFiles: branchSummary.value.readFiles,
|
||||
modifiedFiles: branchSummary.value.modifiedFiles,
|
||||
@@ -798,7 +807,12 @@ export class AgentHarness<
|
||||
const summaryId = await this.session.moveTo(
|
||||
newLeafId,
|
||||
summaryText
|
||||
? { summary: summaryText, details: summaryDetails, fromHook: hookResult?.summary !== undefined }
|
||||
? {
|
||||
summary: summaryText,
|
||||
details: summaryDetails,
|
||||
usage: summaryUsage,
|
||||
fromHook: hookResult?.summary !== undefined,
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
if (summaryId) {
|
||||
|
||||
@@ -252,6 +252,7 @@ export async function generateBranchSummary(
|
||||
|
||||
return ok({
|
||||
summary: summary || "No summary generated",
|
||||
usage: response.usage,
|
||||
readFiles,
|
||||
modifiedFiles,
|
||||
});
|
||||
|
||||
@@ -101,10 +101,35 @@ export interface CompactionResult<T = unknown> {
|
||||
firstKeptEntryId: string;
|
||||
/** Estimated context tokens before compaction. */
|
||||
tokensBefore: number;
|
||||
/** Usage from the LLM call(s) that generated this summary, if available. */
|
||||
usage?: Usage;
|
||||
/** Optional implementation-specific details stored with the compaction entry. */
|
||||
details?: T;
|
||||
}
|
||||
|
||||
function combineUsage(first: Usage, second: Usage): Usage {
|
||||
return {
|
||||
input: first.input + second.input,
|
||||
output: first.output + second.output,
|
||||
cacheRead: first.cacheRead + second.cacheRead,
|
||||
cacheWrite: first.cacheWrite + second.cacheWrite,
|
||||
...(first.cacheWrite1h !== undefined || second.cacheWrite1h !== undefined
|
||||
? { cacheWrite1h: (first.cacheWrite1h ?? 0) + (second.cacheWrite1h ?? 0) }
|
||||
: {}),
|
||||
...(first.reasoning !== undefined || second.reasoning !== undefined
|
||||
? { reasoning: (first.reasoning ?? 0) + (second.reasoning ?? 0) }
|
||||
: {}),
|
||||
totalTokens: first.totalTokens + second.totalTokens,
|
||||
cost: {
|
||||
input: first.cost.input + second.cost.input,
|
||||
output: first.cost.output + second.cost.output,
|
||||
cacheRead: first.cost.cacheRead + second.cost.cacheRead,
|
||||
cacheWrite: first.cost.cacheWrite + second.cost.cacheWrite,
|
||||
total: first.cost.total + second.cost.total,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Compaction thresholds and retention settings. */
|
||||
export interface CompactionSettings {
|
||||
/** Enable automatic compaction decisions. */
|
||||
@@ -474,7 +499,7 @@ export async function generateSummary(
|
||||
customInstructions?: string,
|
||||
previousSummary?: string,
|
||||
thinkingLevel?: ThinkingLevel,
|
||||
): Promise<Result<string, CompactionError>> {
|
||||
): Promise<Result<{ text: string; usage: Usage }, CompactionError>> {
|
||||
const maxTokens = Math.min(
|
||||
Math.floor(0.8 * reserveTokens),
|
||||
model.maxTokens > 0 ? model.maxTokens : Number.POSITIVE_INFINITY,
|
||||
@@ -523,7 +548,7 @@ export async function generateSummary(
|
||||
|
||||
const textContent = contentText(response.content);
|
||||
|
||||
return ok(textContent);
|
||||
return ok({ text: textContent, usage: response.usage });
|
||||
}
|
||||
|
||||
/** Prepared inputs for a compaction run. */
|
||||
@@ -656,22 +681,26 @@ export async function compact(
|
||||
}
|
||||
|
||||
let summary: string;
|
||||
let summaryUsage: Usage;
|
||||
|
||||
if (isSplitTurn && turnPrefixMessages.length > 0) {
|
||||
const historyResult =
|
||||
messagesToSummarize.length > 0
|
||||
? await generateSummary(
|
||||
messagesToSummarize,
|
||||
models,
|
||||
model,
|
||||
settings.reserveTokens,
|
||||
signal,
|
||||
customInstructions,
|
||||
previousSummary,
|
||||
thinkingLevel,
|
||||
)
|
||||
: ok<string, CompactionError>("No prior history.");
|
||||
if (!historyResult.ok) return err(historyResult.error);
|
||||
let historyText = "No prior history.";
|
||||
let historyUsage: Usage | undefined;
|
||||
if (messagesToSummarize.length > 0) {
|
||||
const historyResult = await generateSummary(
|
||||
messagesToSummarize,
|
||||
models,
|
||||
model,
|
||||
settings.reserveTokens,
|
||||
signal,
|
||||
customInstructions,
|
||||
previousSummary,
|
||||
thinkingLevel,
|
||||
);
|
||||
if (!historyResult.ok) return err(historyResult.error);
|
||||
historyText = historyResult.value.text;
|
||||
historyUsage = historyResult.value.usage;
|
||||
}
|
||||
const turnPrefixResult = await generateTurnPrefixSummary(
|
||||
turnPrefixMessages,
|
||||
models,
|
||||
@@ -681,7 +710,10 @@ export async function compact(
|
||||
thinkingLevel,
|
||||
);
|
||||
if (!turnPrefixResult.ok) return err(turnPrefixResult.error);
|
||||
summary = `${historyResult.value}\n\n---\n\n**Turn Context (split turn):**\n\n${turnPrefixResult.value}`;
|
||||
summary = `${historyText}\n\n---\n\n**Turn Context (split turn):**\n\n${turnPrefixResult.value.text}`;
|
||||
summaryUsage = historyUsage
|
||||
? combineUsage(historyUsage, turnPrefixResult.value.usage)
|
||||
: turnPrefixResult.value.usage;
|
||||
} else {
|
||||
const summaryResult = await generateSummary(
|
||||
messagesToSummarize,
|
||||
@@ -694,7 +726,8 @@ export async function compact(
|
||||
thinkingLevel,
|
||||
);
|
||||
if (!summaryResult.ok) return err(summaryResult.error);
|
||||
summary = summaryResult.value;
|
||||
summary = summaryResult.value.text;
|
||||
summaryUsage = summaryResult.value.usage;
|
||||
}
|
||||
|
||||
const { readFiles, modifiedFiles } = computeFileLists(fileOps);
|
||||
@@ -704,6 +737,7 @@ export async function compact(
|
||||
summary,
|
||||
firstKeptEntryId,
|
||||
tokensBefore,
|
||||
usage: summaryUsage,
|
||||
details: { readFiles, modifiedFiles } as CompactionDetails,
|
||||
});
|
||||
}
|
||||
@@ -714,7 +748,7 @@ async function generateTurnPrefixSummary(
|
||||
reserveTokens: number,
|
||||
signal?: AbortSignal,
|
||||
thinkingLevel?: ThinkingLevel,
|
||||
): Promise<Result<string, CompactionError>> {
|
||||
): Promise<Result<{ text: string; usage: Usage }, CompactionError>> {
|
||||
const maxTokens = Math.min(
|
||||
Math.floor(0.5 * reserveTokens),
|
||||
model.maxTokens > 0 ? model.maxTokens : Number.POSITIVE_INFINITY,
|
||||
@@ -749,5 +783,8 @@ async function generateTurnPrefixSummary(
|
||||
);
|
||||
}
|
||||
|
||||
return ok(contentText(response.content));
|
||||
return ok({
|
||||
text: contentText(response.content),
|
||||
usage: response.usage,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ImageContent, TextContent } from "@earendil-works/pi-ai";
|
||||
import type { ImageContent, TextContent, Usage } from "@earendil-works/pi-ai";
|
||||
import type { AgentMessage } from "../../types.ts";
|
||||
import { createBranchSummaryMessage, createCompactionSummaryMessage, createCustomMessage } from "../messages.ts";
|
||||
import type {
|
||||
@@ -247,6 +247,7 @@ export class Session<TMetadata extends SessionMetadata = SessionMetadata> {
|
||||
tokensBefore: number,
|
||||
details?: T,
|
||||
fromHook?: boolean,
|
||||
usage?: Usage,
|
||||
): Promise<string> {
|
||||
return this.appendTypedEntry({
|
||||
type: "compaction",
|
||||
@@ -257,6 +258,7 @@ export class Session<TMetadata extends SessionMetadata = SessionMetadata> {
|
||||
firstKeptEntryId,
|
||||
tokensBefore,
|
||||
details,
|
||||
usage,
|
||||
fromHook,
|
||||
} satisfies CompactionEntry<T>);
|
||||
}
|
||||
@@ -317,7 +319,7 @@ export class Session<TMetadata extends SessionMetadata = SessionMetadata> {
|
||||
|
||||
async moveTo(
|
||||
entryId: string | null,
|
||||
summary?: { summary: string; details?: unknown; fromHook?: boolean },
|
||||
summary?: { summary: string; details?: unknown; usage?: Usage; fromHook?: boolean },
|
||||
): Promise<string | undefined> {
|
||||
if (entryId !== null && !(await this.storage.getEntry(entryId))) {
|
||||
throw new SessionError("not_found", `Entry ${entryId} not found`);
|
||||
@@ -332,6 +334,7 @@ export class Session<TMetadata extends SessionMetadata = SessionMetadata> {
|
||||
fromId: entryId ?? "root",
|
||||
summary: summary.summary,
|
||||
details: summary.details,
|
||||
usage: summary.usage,
|
||||
fromHook: summary.fromHook,
|
||||
} satisfies BranchSummaryEntry);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import type { ImageContent, Model, Models, SimpleStreamOptions, TextContent, Transport } from "@earendil-works/pi-ai";
|
||||
import type {
|
||||
ImageContent,
|
||||
Model,
|
||||
Models,
|
||||
SimpleStreamOptions,
|
||||
TextContent,
|
||||
Transport,
|
||||
Usage,
|
||||
} from "@earendil-works/pi-ai";
|
||||
import type { AgentEvent, AgentMessage, AgentTool, QueueMode, ThinkingLevel } from "../index.ts";
|
||||
import type { Session } from "./session/session.ts";
|
||||
|
||||
@@ -365,6 +373,7 @@ export interface CompactionEntry<T = unknown> extends SessionTreeEntryBase {
|
||||
firstKeptEntryId: string;
|
||||
tokensBefore: number;
|
||||
details?: T;
|
||||
usage?: Usage;
|
||||
fromHook?: boolean;
|
||||
}
|
||||
|
||||
@@ -373,6 +382,7 @@ export interface BranchSummaryEntry<T = unknown> extends SessionTreeEntryBase {
|
||||
fromId: string;
|
||||
summary: string;
|
||||
details?: T;
|
||||
usage?: Usage;
|
||||
fromHook?: boolean;
|
||||
}
|
||||
|
||||
@@ -572,6 +582,7 @@ export interface ToolResultEvent {
|
||||
content: Array<TextContent | ImageContent>;
|
||||
details: unknown;
|
||||
isError: boolean;
|
||||
usage?: Usage;
|
||||
}
|
||||
|
||||
export interface SessionBeforeCompactEvent {
|
||||
@@ -687,6 +698,7 @@ export interface ToolResultPatch {
|
||||
content?: Array<TextContent | ImageContent>;
|
||||
details?: unknown;
|
||||
isError?: boolean;
|
||||
usage?: Usage;
|
||||
terminate?: boolean;
|
||||
}
|
||||
|
||||
@@ -697,7 +709,12 @@ export interface SessionBeforeCompactResult {
|
||||
|
||||
export interface SessionBeforeTreeResult {
|
||||
cancel?: boolean;
|
||||
summary?: { summary: string; details?: unknown };
|
||||
summary?: {
|
||||
summary: string;
|
||||
details?: unknown;
|
||||
/** Usage from the LLM call that generated this summary, if available. */
|
||||
usage?: Usage;
|
||||
};
|
||||
customInstructions?: string;
|
||||
replaceInstructions?: boolean;
|
||||
label?: string;
|
||||
@@ -738,6 +755,8 @@ export interface CompactResult {
|
||||
summary: string;
|
||||
firstKeptEntryId: string;
|
||||
tokensBefore: number;
|
||||
/** Usage from the LLM call(s) that generated this summary, if available. */
|
||||
usage?: Usage;
|
||||
details?: unknown;
|
||||
}
|
||||
|
||||
@@ -793,6 +812,7 @@ export interface GenerateBranchSummaryOptions {
|
||||
|
||||
export interface BranchSummaryResult {
|
||||
summary: string;
|
||||
usage?: Usage;
|
||||
readFiles: string[];
|
||||
modifiedFiles: string[];
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
TextContent,
|
||||
Tool,
|
||||
ToolResultMessage,
|
||||
Usage,
|
||||
} from "@earendil-works/pi-ai";
|
||||
import type { Static, TSchema } from "typebox";
|
||||
|
||||
@@ -69,15 +70,18 @@ export interface BeforeToolCallResult {
|
||||
* - `content`: if provided, replaces the tool result content array in full
|
||||
* - `details`: if provided, replaces the tool result details value in full
|
||||
* - `isError`: if provided, replaces the tool result error flag
|
||||
* - `usage`: if provided, replaces the tool result usage
|
||||
* - `terminate`: if provided, replaces the early-termination hint
|
||||
*
|
||||
* Omitted fields keep the original executed tool result values.
|
||||
* There is no deep merge for `content` or `details`.
|
||||
* There is no deep merge for `content`, `details`, or `usage`.
|
||||
*/
|
||||
export interface AfterToolCallResult {
|
||||
content?: (TextContent | ImageContent)[];
|
||||
details?: unknown;
|
||||
isError?: boolean;
|
||||
/** Usage from the final tool execution itself, if available. Not used for main LLM context accounting. */
|
||||
usage?: Usage;
|
||||
/**
|
||||
* Hint that the agent should stop after the current tool batch.
|
||||
* Early termination only happens when every finalized tool result in the batch sets this to true.
|
||||
@@ -273,6 +277,7 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
|
||||
* - `content` replaces the full content array
|
||||
* - `details` replaces the full details payload
|
||||
* - `isError` replaces the error flag
|
||||
* - `usage` replaces the tool result usage
|
||||
* - `terminate` replaces the early-termination hint
|
||||
*
|
||||
* Any omitted fields keep their original values. No deep merge is performed.
|
||||
@@ -352,6 +357,8 @@ export interface AgentToolResult<T> {
|
||||
content: (TextContent | ImageContent)[];
|
||||
/** Arbitrary structured details for logs or UI rendering. */
|
||||
details: T;
|
||||
/** Usage from the final tool execution itself, if available. Not used for main LLM context accounting. */
|
||||
usage?: Usage;
|
||||
/** Names of tools introduced by this result and available from this transcript point onward. */
|
||||
addedToolNames?: string[];
|
||||
/**
|
||||
|
||||
@@ -239,6 +239,23 @@ describe("agentLoop with AgentMessage", () => {
|
||||
it("should handle tool calls and results", async () => {
|
||||
const toolSchema = Type.Object({ value: Type.String() });
|
||||
const executed: string[] = [];
|
||||
const toolUsage = {
|
||||
input: 1,
|
||||
output: 2,
|
||||
cacheRead: 3,
|
||||
cacheWrite: 4,
|
||||
totalTokens: 10,
|
||||
cost: { input: 0.1, output: 0.2, cacheRead: 0.3, cacheWrite: 0.4, total: 1 },
|
||||
};
|
||||
const patchedToolUsage = {
|
||||
input: 5,
|
||||
output: 6,
|
||||
cacheRead: 7,
|
||||
cacheWrite: 8,
|
||||
totalTokens: 26,
|
||||
cost: { input: 0.5, output: 0.6, cacheRead: 0.7, cacheWrite: 0.8, total: 2.6 },
|
||||
};
|
||||
let observedToolUsage: typeof toolUsage | undefined;
|
||||
const tool: AgentTool<typeof toolSchema, { value: string }> = {
|
||||
name: "echo",
|
||||
label: "Echo",
|
||||
@@ -249,6 +266,7 @@ describe("agentLoop with AgentMessage", () => {
|
||||
return {
|
||||
content: [{ type: "text", text: `echoed: ${params.value}` }],
|
||||
details: { value: params.value },
|
||||
usage: toolUsage,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -264,6 +282,10 @@ describe("agentLoop with AgentMessage", () => {
|
||||
const config: AgentLoopConfig = {
|
||||
model: createModel(),
|
||||
convertToLlm: identityConverter,
|
||||
afterToolCall: async ({ result }) => {
|
||||
observedToolUsage = result.usage;
|
||||
return { usage: patchedToolUsage };
|
||||
},
|
||||
};
|
||||
|
||||
let callIndex = 0;
|
||||
@@ -305,6 +327,10 @@ describe("agentLoop with AgentMessage", () => {
|
||||
if (toolEnd?.type === "tool_execution_end") {
|
||||
expect(toolEnd.isError).toBe(false);
|
||||
}
|
||||
expect(observedToolUsage).toEqual(toolUsage);
|
||||
const messages = await stream.result();
|
||||
const toolResult = messages.find((message) => message.role === "toolResult");
|
||||
expect(toolResult?.role === "toolResult" ? toolResult.usage : undefined).toEqual(patchedToolUsage);
|
||||
});
|
||||
|
||||
it("should not execute tool calls from a length-truncated assistant message", async () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
fauxProvider,
|
||||
fauxToolCall,
|
||||
type RegisterFauxProviderOptions,
|
||||
type Usage,
|
||||
} from "@earendil-works/pi-ai";
|
||||
import { getModel } from "@earendil-works/pi-ai/compat";
|
||||
import { describe, expect, it } from "vitest";
|
||||
@@ -14,7 +15,7 @@ import { InMemorySessionStorage } from "../../src/harness/session/memory-storage
|
||||
import { Session } from "../../src/harness/session/session.ts";
|
||||
import type { PromptTemplate, Skill } from "../../src/harness/types.ts";
|
||||
import type { AgentMessage, AgentTool } from "../../src/types.ts";
|
||||
import { calculateTool } from "../utils/calculate.ts";
|
||||
import { calculateTool, createCalculateToolWithUsage } from "../utils/calculate.ts";
|
||||
import { getCurrentTimeTool } from "../utils/get-current-time.ts";
|
||||
|
||||
interface AppSkill extends Skill {
|
||||
@@ -60,6 +61,34 @@ function getReasoning(options: unknown): unknown {
|
||||
return options.reasoning;
|
||||
}
|
||||
|
||||
function createUsage(input: number, output: number, cacheRead = 0, cacheWrite = 0): Usage {
|
||||
return {
|
||||
input,
|
||||
output,
|
||||
cacheRead,
|
||||
cacheWrite,
|
||||
totalTokens: input + output + cacheRead + cacheWrite,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
function createUserMessage(text: string): AgentMessage {
|
||||
return { role: "user", content: [{ type: "text", text }], timestamp: Date.now() };
|
||||
}
|
||||
|
||||
function createAssistantMessage(text: string): AgentMessage {
|
||||
return {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text }],
|
||||
api: "faux",
|
||||
provider: "faux",
|
||||
model: "faux-1",
|
||||
usage: createUsage(100, 50),
|
||||
stopReason: "stop",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("AgentHarness", () => {
|
||||
it("constructs directly and exposes queue modes", () => {
|
||||
const session = new Session(new InMemorySessionStorage());
|
||||
@@ -427,14 +456,18 @@ describe("AgentHarness", () => {
|
||||
}),
|
||||
]);
|
||||
const session = new Session(new InMemorySessionStorage());
|
||||
const toolUsage = createUsage(1, 2, 3, 4);
|
||||
const patchedToolUsage = createUsage(5, 6, 7, 8);
|
||||
const calculateToolWithUsage = createCalculateToolWithUsage(toolUsage);
|
||||
const harness = new AgentHarness({
|
||||
models,
|
||||
env: new NodeExecutionEnv({ cwd: process.cwd() }),
|
||||
session,
|
||||
model: registration.getModel(),
|
||||
tools: [calculateTool],
|
||||
tools: [calculateToolWithUsage],
|
||||
});
|
||||
const seenToolCalls: Array<{ id: string; name: string; expression: unknown }> = [];
|
||||
let seenToolUsage: Usage | undefined;
|
||||
harness.on("tool_call", (event) => {
|
||||
seenToolCalls.push({ id: event.toolCallId, name: event.toolName, expression: event.input.expression });
|
||||
return undefined;
|
||||
@@ -442,9 +475,11 @@ describe("AgentHarness", () => {
|
||||
harness.on("tool_result", (event) => {
|
||||
expect(event.toolCallId).toBe("call-1");
|
||||
expect(event.toolName).toBe("calculate");
|
||||
seenToolUsage = event.usage;
|
||||
return {
|
||||
content: [{ type: "text", text: "patched result" }],
|
||||
details: { patched: true },
|
||||
usage: patchedToolUsage,
|
||||
terminate: true,
|
||||
};
|
||||
});
|
||||
@@ -455,16 +490,109 @@ describe("AgentHarness", () => {
|
||||
(entry) => entry.type === "message" && entry.message.role === "toolResult",
|
||||
);
|
||||
expect(seenToolCalls).toEqual([{ id: "call-1", name: "calculate", expression: "2 + 2" }]);
|
||||
expect(seenToolUsage).toEqual(toolUsage);
|
||||
expect(toolResult).toMatchObject({
|
||||
type: "message",
|
||||
message: {
|
||||
role: "toolResult",
|
||||
content: [{ type: "text", text: "patched result" }],
|
||||
details: { patched: true },
|
||||
usage: patchedToolUsage,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("persists generated compaction usage", async () => {
|
||||
const registration = newFaux();
|
||||
registration.setResponses([fauxAssistantMessage("## Goal\nTest summary")]);
|
||||
const session = new Session(new InMemorySessionStorage());
|
||||
await session.appendMessage(createUserMessage("one"));
|
||||
await session.appendMessage(createAssistantMessage("two"));
|
||||
const harness = new AgentHarness({
|
||||
models,
|
||||
env: new NodeExecutionEnv({ cwd: process.cwd() }),
|
||||
session,
|
||||
model: registration.getModel(),
|
||||
});
|
||||
|
||||
const result = await harness.compact();
|
||||
const compaction = (await session.getEntries()).find((entry) => entry.type === "compaction");
|
||||
|
||||
expect(result.usage?.totalTokens).toBeGreaterThan(0);
|
||||
expect(compaction?.type === "compaction" ? compaction.usage : undefined).toEqual(result.usage);
|
||||
});
|
||||
|
||||
it("persists hook-provided compaction usage", async () => {
|
||||
const registration = newFaux();
|
||||
const usage = createUsage(5, 6, 7, 8);
|
||||
const session = new Session(new InMemorySessionStorage());
|
||||
await session.appendMessage(createUserMessage("one"));
|
||||
await session.appendMessage(createAssistantMessage("two"));
|
||||
const harness = new AgentHarness({
|
||||
models,
|
||||
env: new NodeExecutionEnv({ cwd: process.cwd() }),
|
||||
session,
|
||||
model: registration.getModel(),
|
||||
});
|
||||
harness.on("session_before_compact", (event) => ({
|
||||
compaction: {
|
||||
summary: "hook summary",
|
||||
firstKeptEntryId: event.preparation.firstKeptEntryId,
|
||||
tokensBefore: event.preparation.tokensBefore,
|
||||
usage,
|
||||
},
|
||||
}));
|
||||
|
||||
const result = await harness.compact();
|
||||
const compaction = (await session.getEntries()).find((entry) => entry.type === "compaction");
|
||||
|
||||
expect(result.usage).toEqual(usage);
|
||||
expect(compaction?.type === "compaction" ? compaction.usage : undefined).toEqual(usage);
|
||||
});
|
||||
|
||||
it("persists generated branch summary usage", async () => {
|
||||
const registration = newFaux();
|
||||
registration.setResponses([fauxAssistantMessage("## Goal\nBranch summary")]);
|
||||
const session = new Session(new InMemorySessionStorage());
|
||||
const targetId = await session.appendMessage(createUserMessage("first branch"));
|
||||
await session.appendMessage(createAssistantMessage("first reply"));
|
||||
await session.appendMessage(createUserMessage("abandoned work"));
|
||||
await session.appendMessage(createAssistantMessage("abandoned reply"));
|
||||
const harness = new AgentHarness({
|
||||
models,
|
||||
env: new NodeExecutionEnv({ cwd: process.cwd() }),
|
||||
session,
|
||||
model: registration.getModel(),
|
||||
});
|
||||
|
||||
const result = await harness.navigateTree(targetId, { summarize: true });
|
||||
|
||||
expect(result.summaryEntry?.usage?.totalTokens).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("persists hook-provided branch summary usage", async () => {
|
||||
const registration = newFaux();
|
||||
const usage = createUsage(13, 14, 15, 16);
|
||||
const session = new Session(new InMemorySessionStorage());
|
||||
const targetId = await session.appendMessage(createUserMessage("first branch"));
|
||||
await session.appendMessage(createAssistantMessage("first reply"));
|
||||
await session.appendMessage(createUserMessage("abandoned work"));
|
||||
await session.appendMessage(createAssistantMessage("abandoned reply"));
|
||||
const harness = new AgentHarness({
|
||||
models,
|
||||
env: new NodeExecutionEnv({ cwd: process.cwd() }),
|
||||
session,
|
||||
model: registration.getModel(),
|
||||
});
|
||||
harness.on("session_before_tree", () => ({
|
||||
summary: { summary: "hook branch summary", usage },
|
||||
}));
|
||||
|
||||
const result = await harness.navigateTree(targetId, { summarize: true });
|
||||
|
||||
expect(result.summaryEntry?.usage).toEqual(usage);
|
||||
});
|
||||
|
||||
it("preserves app tool types for getters and update events", async () => {
|
||||
const session = new Session(new InMemorySessionStorage());
|
||||
const env = new NodeExecutionEnv({ cwd: process.cwd() });
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
fauxProvider,
|
||||
type Message,
|
||||
type Model,
|
||||
type Models,
|
||||
type Usage,
|
||||
} from "@earendil-works/pi-ai";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
@@ -142,6 +143,17 @@ function createFauxModel(reasoning: boolean, maxTokens = 8192): { faux: FauxProv
|
||||
return { faux, model: faux.getModel() };
|
||||
}
|
||||
|
||||
function createModelsWithSimpleResponses(responses: AssistantMessage[]): Models {
|
||||
const remaining = [...responses];
|
||||
const stub = Object.create(models) as Models;
|
||||
stub.completeSimple = async () => {
|
||||
const response = remaining.shift();
|
||||
if (!response) throw new Error("No faux completeSimple response queued");
|
||||
return response;
|
||||
};
|
||||
return stub;
|
||||
}
|
||||
|
||||
describe("harness compaction", () => {
|
||||
beforeEach(() => {
|
||||
nextId = 0;
|
||||
@@ -501,7 +513,12 @@ describe("harness compaction", () => {
|
||||
await generateSummary(messages, models, model, 2000, undefined, "focus", "old summary"),
|
||||
);
|
||||
|
||||
expect(summary).toContain("Test summary");
|
||||
expect(summary.text).toContain("Test summary");
|
||||
expect(summary.usage.input).toBeGreaterThan(0);
|
||||
expect(summary.usage.output).toBeGreaterThan(0);
|
||||
expect(summary.usage.totalTokens).toBe(
|
||||
summary.usage.input + summary.usage.output + summary.usage.cacheRead + summary.usage.cacheWrite,
|
||||
);
|
||||
expect(promptText).toContain("<previous-summary>\nold summary\n</previous-summary>");
|
||||
expect(promptText).toContain("Additional focus: focus");
|
||||
});
|
||||
@@ -578,6 +595,30 @@ describe("harness compaction", () => {
|
||||
expect(invalidResult).toMatchObject({ ok: false, error: { code: "invalid_session" } });
|
||||
});
|
||||
|
||||
it("combines usage for split-turn compaction summaries", async () => {
|
||||
const messages: AgentMessage[] = [createUserMessage("Summarize this.")];
|
||||
const { model } = createFauxModel(false);
|
||||
const historyUsage = createMockUsage(1, 2, 3, 4);
|
||||
const turnPrefixUsage = createMockUsage(5, 6, 7, 8);
|
||||
const usageModels = createModelsWithSimpleResponses([
|
||||
{ ...fauxAssistantMessage("history summary"), usage: historyUsage },
|
||||
{ ...fauxAssistantMessage("turn prefix summary"), usage: turnPrefixUsage },
|
||||
]);
|
||||
const preparation: CompactionPreparation = {
|
||||
firstKeptEntryId: "entry-keep",
|
||||
messagesToSummarize: messages,
|
||||
turnPrefixMessages: messages,
|
||||
isSplitTurn: true,
|
||||
tokensBefore: 100,
|
||||
fileOps: { read: new Set(), written: new Set(), edited: new Set() },
|
||||
settings: { enabled: true, reserveTokens: 2000, keepRecentTokens: 20 },
|
||||
};
|
||||
|
||||
const result = getOrThrow(await compact(preparation, usageModels, model));
|
||||
|
||||
expect(result.usage).toEqual(createMockUsage(6, 8, 10, 12));
|
||||
});
|
||||
|
||||
it("passes reasoning through turn-prefix summaries when enabled", async () => {
|
||||
const messages: AgentMessage[] = [createUserMessage("Summarize this.")];
|
||||
const seenOptions: Array<Record<string, unknown> | undefined> = [];
|
||||
@@ -646,6 +687,7 @@ describe("harness compaction", () => {
|
||||
const result = getOrThrow(await compact(preparation!, models, model));
|
||||
expect(result.summary.length).toBeGreaterThan(0);
|
||||
expect(result.firstKeptEntryId).toBeTruthy();
|
||||
expect(result.usage?.totalTokens).toBeGreaterThan(0);
|
||||
expect(result.details).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -86,6 +86,49 @@ async function runSessionSuite(
|
||||
expect(context.messages[1]?.role).toBe("branchSummary");
|
||||
});
|
||||
|
||||
it("persists compaction usage", async () => {
|
||||
const session = new Session(await createStorage());
|
||||
const firstKeptEntryId = await session.appendMessage(createUserMessage("one"));
|
||||
const usage = {
|
||||
input: 1,
|
||||
output: 2,
|
||||
cacheRead: 3,
|
||||
cacheWrite: 4,
|
||||
totalTokens: 10,
|
||||
cost: { input: 0.1, output: 0.2, cacheRead: 0.3, cacheWrite: 0.4, total: 1 },
|
||||
};
|
||||
|
||||
const compactionId = await session.appendCompaction(
|
||||
"summary",
|
||||
firstKeptEntryId,
|
||||
1234,
|
||||
undefined,
|
||||
false,
|
||||
usage,
|
||||
);
|
||||
|
||||
const compactionEntry = await session.getEntry(compactionId);
|
||||
expect(compactionEntry?.type === "compaction" ? compactionEntry.usage : undefined).toEqual(usage);
|
||||
});
|
||||
|
||||
it("persists branch summary usage", async () => {
|
||||
const session = new Session(await createStorage());
|
||||
const user1 = await session.appendMessage(createUserMessage("one"));
|
||||
const usage = {
|
||||
input: 1,
|
||||
output: 2,
|
||||
cacheRead: 3,
|
||||
cacheWrite: 4,
|
||||
totalTokens: 10,
|
||||
cost: { input: 0.1, output: 0.2, cacheRead: 0.3, cacheWrite: 0.4, total: 1 },
|
||||
};
|
||||
|
||||
const summaryId = await session.moveTo(user1, { summary: "summary text", usage });
|
||||
|
||||
const summaryEntry = await session.getEntry(summaryId!);
|
||||
expect(summaryEntry?.type === "branch_summary" ? summaryEntry.usage : undefined).toEqual(usage);
|
||||
});
|
||||
|
||||
it("supports custom message entries in context", async () => {
|
||||
const session = new Session(await createStorage());
|
||||
await session.appendMessage(createUserMessage("one"));
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Usage } from "@earendil-works/pi-ai";
|
||||
import { type Static, Type } from "typebox";
|
||||
import type { AgentTool, AgentToolResult } from "../../src/types.ts";
|
||||
|
||||
@@ -30,3 +31,10 @@ export const calculateTool: AgentTool<typeof calculateSchema, undefined> = {
|
||||
return calculate(args.expression);
|
||||
},
|
||||
};
|
||||
|
||||
export function createCalculateToolWithUsage(usage: Usage): AgentTool<typeof calculateSchema, undefined> {
|
||||
return {
|
||||
...calculateTool,
|
||||
execute: async (_toolCallId: string, args: CalculateParams) => ({ ...calculate(args.expression), usage }),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user