426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
1331 lines
36 KiB
TypeScript
1331 lines
36 KiB
TypeScript
// Shares script lib trajectory validate helpers across repo automation entrypoints.
|
|
import { computeCallCostUsd } from "./cost-table";
|
|
|
|
export type RecordedStageKind =
|
|
| "messageHandler"
|
|
| "planner"
|
|
| "tool"
|
|
| "evaluation"
|
|
| "subPlanner"
|
|
| "compaction";
|
|
|
|
export interface ChatMessage {
|
|
role: string;
|
|
content: unknown;
|
|
}
|
|
|
|
export interface ToolDefinition {
|
|
name?: string;
|
|
description?: string;
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
export interface ToolCallRecord {
|
|
id?: string;
|
|
name?: string;
|
|
args?: Record<string, unknown>;
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
export interface UsageBreakdown {
|
|
promptTokens: number;
|
|
completionTokens: number;
|
|
cacheReadInputTokens?: number;
|
|
cacheCreationInputTokens?: number;
|
|
totalTokens: number;
|
|
}
|
|
|
|
export interface ModelCallRecord {
|
|
modelType: string;
|
|
modelName?: string;
|
|
provider: string;
|
|
prompt: string;
|
|
messages?: ChatMessage[];
|
|
tools?: ToolDefinition[];
|
|
toolChoice?: unknown;
|
|
response: string;
|
|
toolCalls?: ToolCallRecord[];
|
|
usage?: UsageBreakdown;
|
|
finishReason?: string;
|
|
costUsd?: number;
|
|
}
|
|
|
|
export interface ToolStageRecord {
|
|
name: string;
|
|
args: Record<string, unknown>;
|
|
result: unknown;
|
|
success: boolean;
|
|
durationMs: number;
|
|
}
|
|
|
|
export interface EvaluationRecord {
|
|
success: boolean;
|
|
decision: string;
|
|
thought?: string;
|
|
messageToUser?: string;
|
|
copyToClipboard?: unknown;
|
|
recommendedToolCallId?: string;
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
export interface CacheStageRecord {
|
|
segmentHashes: string[];
|
|
prefixHash: string;
|
|
diffFromPriorStage?: { added: number; unchanged: number; removed: number };
|
|
}
|
|
|
|
export interface RecordedStage {
|
|
stageId: string;
|
|
kind: RecordedStageKind;
|
|
iteration?: number;
|
|
parentStageId?: string;
|
|
startedAt: number;
|
|
endedAt: number;
|
|
latencyMs: number;
|
|
model?: ModelCallRecord;
|
|
tool?: ToolStageRecord;
|
|
evaluation?: EvaluationRecord;
|
|
cache?: CacheStageRecord;
|
|
}
|
|
|
|
export interface RecordedTrajectoryMetrics {
|
|
totalLatencyMs: number;
|
|
totalPromptTokens: number;
|
|
totalCompletionTokens: number;
|
|
totalCacheReadTokens: number;
|
|
totalCacheCreationTokens: number;
|
|
totalCostUsd: number;
|
|
plannerIterations: number;
|
|
toolCallsExecuted: number;
|
|
toolCallFailures: number;
|
|
evaluatorFailures: number;
|
|
finalDecision?: "FINISH" | "CONTINUE" | "max_iterations" | "error";
|
|
}
|
|
|
|
export interface RecordedTrajectory {
|
|
trajectoryId: string;
|
|
agentId: string;
|
|
roomId?: string;
|
|
rootMessage: { id: string; text: string; sender?: string };
|
|
startedAt: number;
|
|
endedAt?: number;
|
|
status: "running" | "finished" | "errored";
|
|
stages: RecordedStage[];
|
|
metrics: RecordedTrajectoryMetrics;
|
|
}
|
|
|
|
export type ValidationSeverity = "error" | "warning";
|
|
|
|
export interface ValidationIssue {
|
|
severity: ValidationSeverity;
|
|
path: string;
|
|
message: string;
|
|
}
|
|
|
|
export interface TrajectoryRollup {
|
|
totalLatencyMs: number;
|
|
totalPromptTokens: number;
|
|
totalCompletionTokens: number;
|
|
totalCacheReadTokens: number;
|
|
totalCacheCreationTokens: number;
|
|
totalCostUsd: number;
|
|
plannerIterations: number;
|
|
toolCallsExecuted: number;
|
|
toolCallFailures: number;
|
|
evaluatorStages: number;
|
|
evaluatorSuccesses: number;
|
|
evaluatorFailures: number;
|
|
modelCallStages: number;
|
|
toolResultSuccesses: number;
|
|
toolResultFailures: number;
|
|
}
|
|
|
|
/**
|
|
* Machine-readable live-vs-fabricated grade for a trajectory's evidence,
|
|
* derived from the providers its model stages recorded (#13623):
|
|
* - `live` — at least one model stage names a real provider (not the
|
|
* deterministic proxy, not the `"default"` placeholder, not empty).
|
|
* - `proxy` — every model stage was served by the deterministic LLM proxy.
|
|
* - `mock` — no model stage names any real provider (all `"default"`/empty) and
|
|
* no proxy stage either; the trajectory can't prove which model,
|
|
* if any, answered.
|
|
* - `unknown`— there are no model stages to grade.
|
|
*/
|
|
export type TrajectoryEvidenceGrade = "live" | "proxy" | "mock" | "unknown";
|
|
|
|
/**
|
|
* Provider name recorded by the scenario-runner's deterministic LLM proxy. A
|
|
* trajectory served entirely by it is NOT live evidence.
|
|
*/
|
|
export const DETERMINISTIC_LLM_PROXY_PROVIDER_NAME =
|
|
"deterministic-llm-proxy" as const;
|
|
|
|
/**
|
|
* Provider values that do NOT identify a real live model: the deterministic
|
|
* proxy, the `"default"` placeholder some stages used to hardcode, and
|
|
* empty/whitespace.
|
|
*/
|
|
function isNonLiveProviderName(provider: unknown): boolean {
|
|
if (typeof provider !== "string") return true;
|
|
const trimmed = provider.trim();
|
|
if (trimmed.length === 0) return true;
|
|
if (trimmed === DETERMINISTIC_LLM_PROXY_PROVIDER_NAME) return true;
|
|
if (trimmed === "default") return true;
|
|
return false;
|
|
}
|
|
|
|
export interface TrajectoryValidationResult {
|
|
ok: boolean;
|
|
errorCount: number;
|
|
warningCount: number;
|
|
issues: ValidationIssue[];
|
|
rollup: TrajectoryRollup;
|
|
selectedContexts: string[];
|
|
stageKinds: string[];
|
|
/**
|
|
* Live-vs-fabricated grade for this trajectory's evidence, derived from the
|
|
* providers its model stages recorded (#13623). CI evidence gates can
|
|
* read this instead of a human eyeballing `report.providerName`.
|
|
*/
|
|
evidenceGrade: TrajectoryEvidenceGrade;
|
|
}
|
|
|
|
export interface ValidateTrajectoryOptions {
|
|
expectedStages?: string[];
|
|
expectedContexts?: string[];
|
|
costToleranceUsd?: number;
|
|
requireMessageArrays?: boolean;
|
|
requireContextEvidence?: boolean;
|
|
/**
|
|
* Opt-in live-provider gate (#13623). When true, a trajectory whose evidence
|
|
* grade is not `live` — i.e. every model stage was served by the
|
|
* deterministic proxy, `"default"`, or an empty provider — fails validation
|
|
* with an error. Off by default so structural validation is unchanged; the
|
|
* evidence / CI convention turns it on to reject proxy-produced
|
|
* "proof".
|
|
*/
|
|
requireLiveProvider?: boolean;
|
|
}
|
|
|
|
export interface CompareTrajectorySummary {
|
|
idA: string;
|
|
idB: string;
|
|
a: TrajectoryRollup;
|
|
b: TrajectoryRollup;
|
|
delta: TrajectoryRollup;
|
|
cacheHitRateA: number;
|
|
cacheHitRateB: number;
|
|
cacheHitRateDelta: number;
|
|
estimatedBatchingDelta: {
|
|
modelCalls: number;
|
|
plannerIterations: number;
|
|
stages: number;
|
|
toolCalls: number;
|
|
};
|
|
}
|
|
|
|
const STAGE_KINDS = new Set<RecordedStageKind>([
|
|
"messageHandler",
|
|
"planner",
|
|
"tool",
|
|
"evaluation",
|
|
"subPlanner",
|
|
"compaction",
|
|
]);
|
|
|
|
const MODEL_STAGE_KINDS = new Set<RecordedStageKind>([
|
|
"messageHandler",
|
|
"planner",
|
|
"evaluation",
|
|
"subPlanner",
|
|
"compaction",
|
|
]);
|
|
|
|
const DEFAULT_ROLLUP: TrajectoryRollup = {
|
|
totalLatencyMs: 0,
|
|
totalPromptTokens: 0,
|
|
totalCompletionTokens: 0,
|
|
totalCacheReadTokens: 0,
|
|
totalCacheCreationTokens: 0,
|
|
totalCostUsd: 0,
|
|
plannerIterations: 0,
|
|
toolCallsExecuted: 0,
|
|
toolCallFailures: 0,
|
|
evaluatorStages: 0,
|
|
evaluatorSuccesses: 0,
|
|
evaluatorFailures: 0,
|
|
modelCallStages: 0,
|
|
toolResultSuccesses: 0,
|
|
toolResultFailures: 0,
|
|
};
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
function cloneRollup(): TrajectoryRollup {
|
|
return { ...DEFAULT_ROLLUP };
|
|
}
|
|
|
|
function pushIssue(
|
|
issues: ValidationIssue[],
|
|
severity: ValidationSeverity,
|
|
path: string,
|
|
message: string,
|
|
): void {
|
|
issues.push({ severity, path, message });
|
|
}
|
|
|
|
function requireString(
|
|
value: unknown,
|
|
issues: ValidationIssue[],
|
|
path: string,
|
|
opts: { nonEmpty?: boolean } = {},
|
|
): value is string {
|
|
if (typeof value !== "string") {
|
|
pushIssue(issues, "error", path, "must be a string");
|
|
return false;
|
|
}
|
|
if (opts.nonEmpty && value.trim().length === 0) {
|
|
pushIssue(issues, "error", path, "must be a non-empty string");
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function requireNumber(
|
|
value: unknown,
|
|
issues: ValidationIssue[],
|
|
path: string,
|
|
opts: { integer?: boolean; min?: number } = {},
|
|
): value is number {
|
|
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
pushIssue(issues, "error", path, "must be a finite number");
|
|
return false;
|
|
}
|
|
if (opts.integer && !Number.isInteger(value)) {
|
|
pushIssue(issues, "error", path, "must be an integer");
|
|
return false;
|
|
}
|
|
if (opts.min !== undefined && value < opts.min) {
|
|
pushIssue(issues, "error", path, `must be >= ${opts.min}`);
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function parseJsonObject(
|
|
text: string | undefined,
|
|
): Record<string, unknown> | null {
|
|
if (!text) return null;
|
|
try {
|
|
const parsed = JSON.parse(text);
|
|
return isRecord(parsed) ? parsed : null;
|
|
} catch {
|
|
const start = text.indexOf("{");
|
|
const end = text.lastIndexOf("}");
|
|
if (start < 0 || end <= start) return null;
|
|
try {
|
|
const parsed = JSON.parse(text.slice(start, end + 1));
|
|
return isRecord(parsed) ? parsed : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
|
|
function numberDelta(a: number, b: number): number {
|
|
return b - a;
|
|
}
|
|
|
|
function nearEqual(a: number, b: number, tolerance: number): boolean {
|
|
return Math.abs(a - b) <= tolerance;
|
|
}
|
|
|
|
function metricNumber(
|
|
metrics: Record<string, unknown> | undefined,
|
|
key: keyof RecordedTrajectoryMetrics,
|
|
issues: ValidationIssue[],
|
|
): number {
|
|
const value = metrics?.[key];
|
|
requireNumber(value, issues, `$.metrics.${key}`, { min: 0 });
|
|
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
}
|
|
|
|
function stagePath(index: number, suffix = ""): string {
|
|
return `$.stages[${index}]${suffix}`;
|
|
}
|
|
|
|
function validateUsage(
|
|
usage: unknown,
|
|
issues: ValidationIssue[],
|
|
path: string,
|
|
): UsageBreakdown | undefined {
|
|
if (usage === undefined) return undefined;
|
|
if (!isRecord(usage)) {
|
|
pushIssue(issues, "error", path, "must be an object");
|
|
return undefined;
|
|
}
|
|
const promptTokens = usage.promptTokens;
|
|
const completionTokens = usage.completionTokens;
|
|
const totalTokens = usage.totalTokens;
|
|
requireNumber(promptTokens, issues, `${path}.promptTokens`, {
|
|
integer: true,
|
|
min: 0,
|
|
});
|
|
requireNumber(completionTokens, issues, `${path}.completionTokens`, {
|
|
integer: true,
|
|
min: 0,
|
|
});
|
|
requireNumber(totalTokens, issues, `${path}.totalTokens`, {
|
|
integer: true,
|
|
min: 0,
|
|
});
|
|
|
|
const cacheRead = usage.cacheReadInputTokens;
|
|
const cacheCreate = usage.cacheCreationInputTokens;
|
|
if (cacheRead !== undefined) {
|
|
requireNumber(cacheRead, issues, `${path}.cacheReadInputTokens`, {
|
|
integer: true,
|
|
min: 0,
|
|
});
|
|
}
|
|
if (cacheCreate !== undefined) {
|
|
requireNumber(cacheCreate, issues, `${path}.cacheCreationInputTokens`, {
|
|
integer: true,
|
|
min: 0,
|
|
});
|
|
}
|
|
|
|
if (
|
|
typeof promptTokens === "number" &&
|
|
typeof completionTokens === "number" &&
|
|
typeof totalTokens === "number" &&
|
|
totalTokens !== promptTokens + completionTokens
|
|
) {
|
|
pushIssue(
|
|
issues,
|
|
"error",
|
|
`${path}.totalTokens`,
|
|
"must equal promptTokens + completionTokens",
|
|
);
|
|
}
|
|
if (
|
|
typeof promptTokens === "number" &&
|
|
typeof cacheRead === "number" &&
|
|
cacheRead > promptTokens
|
|
) {
|
|
pushIssue(
|
|
issues,
|
|
"error",
|
|
`${path}.cacheReadInputTokens`,
|
|
"must be <= promptTokens",
|
|
);
|
|
}
|
|
if (
|
|
typeof promptTokens === "number" &&
|
|
typeof cacheCreate === "number" &&
|
|
cacheCreate > promptTokens
|
|
) {
|
|
pushIssue(
|
|
issues,
|
|
"error",
|
|
`${path}.cacheCreationInputTokens`,
|
|
"must be <= promptTokens",
|
|
);
|
|
}
|
|
if (
|
|
typeof promptTokens === "number" &&
|
|
typeof cacheRead === "number" &&
|
|
typeof cacheCreate === "number" &&
|
|
cacheRead + cacheCreate > promptTokens
|
|
) {
|
|
pushIssue(
|
|
issues,
|
|
"error",
|
|
path,
|
|
"cacheReadInputTokens + cacheCreationInputTokens must be <= promptTokens",
|
|
);
|
|
}
|
|
|
|
return {
|
|
promptTokens: promptTokens as number,
|
|
completionTokens: completionTokens as number,
|
|
totalTokens: totalTokens as number,
|
|
...(cacheRead !== undefined
|
|
? { cacheReadInputTokens: cacheRead as number }
|
|
: {}),
|
|
...(cacheCreate !== undefined
|
|
? { cacheCreationInputTokens: cacheCreate as number }
|
|
: {}),
|
|
};
|
|
}
|
|
|
|
function validateModel(
|
|
model: unknown,
|
|
stage: RecordedStage,
|
|
index: number,
|
|
issues: ValidationIssue[],
|
|
opts: ValidateTrajectoryOptions,
|
|
): ModelCallRecord | undefined {
|
|
const path = stagePath(index, ".model");
|
|
if (!isRecord(model)) {
|
|
pushIssue(issues, "error", path, "is required for model stages");
|
|
return undefined;
|
|
}
|
|
requireString(model.modelType, issues, `${path}.modelType`, {
|
|
nonEmpty: true,
|
|
});
|
|
requireString(model.provider, issues, `${path}.provider`, { nonEmpty: true });
|
|
requireString(model.prompt, issues, `${path}.prompt`, { nonEmpty: true });
|
|
if (!("response" in model)) {
|
|
pushIssue(issues, "error", `${path}.response`, "is required");
|
|
} else {
|
|
requireString(model.response, issues, `${path}.response`);
|
|
}
|
|
if (typeof model.modelName !== "string" || model.modelName.trim() === "") {
|
|
pushIssue(
|
|
issues,
|
|
"warning",
|
|
`${path}.modelName`,
|
|
"is missing; cost validation may be partial",
|
|
);
|
|
}
|
|
|
|
if (!Array.isArray(model.messages)) {
|
|
pushIssue(
|
|
issues,
|
|
opts.requireMessageArrays ? "error" : "warning",
|
|
`${path}.messages`,
|
|
"should contain the full chat messages sent to the provider",
|
|
);
|
|
} else if (model.messages.length === 0) {
|
|
pushIssue(
|
|
issues,
|
|
opts.requireMessageArrays ? "error" : "warning",
|
|
`${path}.messages`,
|
|
"should not be empty",
|
|
);
|
|
}
|
|
|
|
const needsToolArrays =
|
|
stage.kind === "planner" || stage.kind === "subPlanner";
|
|
if (!Array.isArray(model.tools)) {
|
|
pushIssue(
|
|
issues,
|
|
needsToolArrays ? "error" : "warning",
|
|
`${path}.tools`,
|
|
"should be present as an array for reviewability",
|
|
);
|
|
} else if (needsToolArrays && model.tools.length === 0) {
|
|
pushIssue(issues, "error", `${path}.tools`, "must include callable tools");
|
|
}
|
|
if (!Array.isArray(model.toolCalls)) {
|
|
pushIssue(
|
|
issues,
|
|
needsToolArrays ? "error" : "warning",
|
|
`${path}.toolCalls`,
|
|
"should be present as an array for reviewability",
|
|
);
|
|
}
|
|
|
|
const usage = validateUsage(model.usage, issues, `${path}.usage`);
|
|
if (usage === undefined) {
|
|
pushIssue(
|
|
issues,
|
|
"warning",
|
|
`${path}.usage`,
|
|
"is missing; token and cost rollups cannot be fully audited",
|
|
);
|
|
}
|
|
if (model.costUsd !== undefined) {
|
|
requireNumber(model.costUsd, issues, `${path}.costUsd`, { min: 0 });
|
|
}
|
|
if (usage && typeof model.modelName === "string") {
|
|
const expectedCost = computeCallCostUsd(model.modelName, usage);
|
|
if (
|
|
expectedCost > 0 &&
|
|
typeof model.costUsd === "number" &&
|
|
!nearEqual(model.costUsd, expectedCost, opts.costToleranceUsd ?? 0.000001)
|
|
) {
|
|
pushIssue(
|
|
issues,
|
|
"error",
|
|
`${path}.costUsd`,
|
|
`does not match price table: expected ${expectedCost}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
return model as unknown as ModelCallRecord;
|
|
}
|
|
|
|
function validateStageSequence(
|
|
stages: RecordedStage[],
|
|
issues: ValidationIssue[],
|
|
expectedStages?: string[],
|
|
): void {
|
|
if (stages.length === 0) {
|
|
pushIssue(issues, "error", "$.stages", "must contain at least one stage");
|
|
return;
|
|
}
|
|
if (stages[0]?.kind !== "messageHandler") {
|
|
pushIssue(
|
|
issues,
|
|
"error",
|
|
"$.stages[0].kind",
|
|
"first stage must be messageHandler",
|
|
);
|
|
}
|
|
const actualKinds = stages.map((stage) => stage.kind);
|
|
if (expectedStages && expectedStages.length > 0) {
|
|
if (actualKinds.length !== expectedStages.length) {
|
|
pushIssue(
|
|
issues,
|
|
"error",
|
|
"$.stages",
|
|
`expected ${expectedStages.length} stages, found ${actualKinds.length}`,
|
|
);
|
|
}
|
|
for (
|
|
let i = 0;
|
|
i < Math.max(actualKinds.length, expectedStages.length);
|
|
i++
|
|
) {
|
|
if (actualKinds[i] !== expectedStages[i]) {
|
|
pushIssue(
|
|
issues,
|
|
"error",
|
|
stagePath(i, ".kind"),
|
|
`expected ${expectedStages[i] ?? "<missing>"}, found ${actualKinds[i] ?? "<missing>"}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
let seenPlanner = false;
|
|
let sawToolSincePlanner = false;
|
|
const pendingToolCalls: string[] = [];
|
|
const stageIds = new Set<string>();
|
|
for (let i = 0; i < stages.length; i++) {
|
|
const stage = stages[i];
|
|
if (!stage) continue;
|
|
if (stageIds.has(stage.stageId)) {
|
|
pushIssue(issues, "error", stagePath(i, ".stageId"), "must be unique");
|
|
}
|
|
stageIds.add(stage.stageId);
|
|
|
|
if (stage.parentStageId && !stageIds.has(stage.parentStageId)) {
|
|
pushIssue(
|
|
issues,
|
|
"warning",
|
|
stagePath(i, ".parentStageId"),
|
|
"does not refer to an earlier stageId",
|
|
);
|
|
}
|
|
|
|
if (stage.kind === "messageHandler" && i !== 0) {
|
|
pushIssue(
|
|
issues,
|
|
"error",
|
|
stagePath(i, ".kind"),
|
|
"messageHandler can only appear first",
|
|
);
|
|
}
|
|
if (stage.kind === "planner" || stage.kind === "subPlanner") {
|
|
seenPlanner = true;
|
|
for (const call of stage.model?.toolCalls ?? []) {
|
|
if (call.name) pendingToolCalls.push(call.name);
|
|
}
|
|
}
|
|
if (stage.kind === "tool") {
|
|
if (!seenPlanner) {
|
|
pushIssue(
|
|
issues,
|
|
"error",
|
|
stagePath(i, ".kind"),
|
|
"tool stage must follow a planner/subPlanner stage",
|
|
);
|
|
}
|
|
sawToolSincePlanner = true;
|
|
const toolName = stage.tool?.name;
|
|
if (toolName && pendingToolCalls.length > 0) {
|
|
const idx = pendingToolCalls.indexOf(toolName);
|
|
if (idx >= 0) {
|
|
pendingToolCalls.splice(idx, 1);
|
|
} else {
|
|
pushIssue(
|
|
issues,
|
|
"warning",
|
|
stagePath(i, ".tool.name"),
|
|
"does not match any pending planner toolCall",
|
|
);
|
|
}
|
|
}
|
|
}
|
|
if (stage.kind === "evaluation") {
|
|
if (!sawToolSincePlanner) {
|
|
pushIssue(
|
|
issues,
|
|
"error",
|
|
stagePath(i, ".kind"),
|
|
"evaluation stage must follow a tool result",
|
|
);
|
|
}
|
|
sawToolSincePlanner = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
function extractSelectedContexts(
|
|
stages: RecordedStage[],
|
|
issues: ValidationIssue[],
|
|
): string[] {
|
|
const handler = stages.find((stage) => stage.kind === "messageHandler");
|
|
const parsed = parseJsonObject(handler?.model?.response);
|
|
if (!parsed) {
|
|
pushIssue(
|
|
issues,
|
|
"warning",
|
|
"$.stages[0].model.response",
|
|
"messageHandler response is not parseable JSON; context selection cannot be audited",
|
|
);
|
|
return [];
|
|
}
|
|
const contexts = parsed.contexts;
|
|
if (!Array.isArray(contexts)) {
|
|
pushIssue(
|
|
issues,
|
|
"warning",
|
|
"$.stages[0].model.response.contexts",
|
|
"is missing; context selection cannot be audited",
|
|
);
|
|
return [];
|
|
}
|
|
return contexts.filter(
|
|
(context): context is string => typeof context === "string",
|
|
);
|
|
}
|
|
|
|
function validateContextEvidence(
|
|
stages: RecordedStage[],
|
|
selectedContexts: string[],
|
|
issues: ValidationIssue[],
|
|
opts: ValidateTrajectoryOptions,
|
|
): void {
|
|
if (opts.requireContextEvidence === false) return;
|
|
const handlerPrompt = stages[0]?.model?.prompt ?? "";
|
|
if (
|
|
!handlerPrompt.includes("available_contexts") &&
|
|
!handlerPrompt.includes("contextRegistryDigest")
|
|
) {
|
|
pushIssue(
|
|
issues,
|
|
"warning",
|
|
"$.stages[0].model.prompt",
|
|
"lacks available context catalog evidence",
|
|
);
|
|
}
|
|
for (const expected of opts.expectedContexts ?? []) {
|
|
if (!selectedContexts.includes(expected)) {
|
|
pushIssue(
|
|
issues,
|
|
"error",
|
|
"$.stages[0].model.response.contexts",
|
|
`missing expected context "${expected}"`,
|
|
);
|
|
}
|
|
}
|
|
if (selectedContexts.length === 0) return;
|
|
|
|
const laterPrompts = stages
|
|
.slice(1)
|
|
.map((stage) => stage.model?.prompt ?? "")
|
|
.filter(Boolean);
|
|
const combined = laterPrompts.join("\n");
|
|
for (const context of selectedContexts) {
|
|
if (!combined.includes(context)) {
|
|
pushIssue(
|
|
issues,
|
|
"warning",
|
|
"$.stages[*].model.prompt",
|
|
`selected context "${context}" is not visible in planner/evaluator prompts`,
|
|
);
|
|
}
|
|
}
|
|
if (
|
|
!combined.includes("selected_contexts") &&
|
|
!combined.includes("selectedContexts")
|
|
) {
|
|
pushIssue(
|
|
issues,
|
|
"warning",
|
|
"$.stages[*].model.prompt",
|
|
"lacks selected context evidence after Stage 1",
|
|
);
|
|
}
|
|
if (
|
|
!combined.includes("contextProviders") &&
|
|
!combined.includes("contextDefinitions") &&
|
|
!combined.includes("expandedTools") &&
|
|
!combined.includes("provider")
|
|
) {
|
|
pushIssue(
|
|
issues,
|
|
"warning",
|
|
"$.stages[*].model.prompt",
|
|
"lacks enriched context/provider evidence",
|
|
);
|
|
}
|
|
}
|
|
|
|
function validateMetrics(
|
|
trajectory: RecordedTrajectory,
|
|
rollup: TrajectoryRollup,
|
|
issues: ValidationIssue[],
|
|
opts: ValidateTrajectoryOptions,
|
|
): void {
|
|
const metrics = trajectory.metrics as unknown as
|
|
| Record<string, unknown>
|
|
| undefined;
|
|
if (!isRecord(metrics)) {
|
|
pushIssue(issues, "error", "$.metrics", "must be an object");
|
|
return;
|
|
}
|
|
const tolerance = opts.costToleranceUsd ?? 0.000001;
|
|
const checks: Array<[keyof RecordedTrajectoryMetrics, number, number]> = [
|
|
["totalLatencyMs", rollup.totalLatencyMs, 0],
|
|
["totalPromptTokens", rollup.totalPromptTokens, 0],
|
|
["totalCompletionTokens", rollup.totalCompletionTokens, 0],
|
|
["totalCacheReadTokens", rollup.totalCacheReadTokens, 0],
|
|
["totalCacheCreationTokens", rollup.totalCacheCreationTokens, 0],
|
|
["plannerIterations", rollup.plannerIterations, 0],
|
|
["toolCallsExecuted", rollup.toolCallsExecuted, 0],
|
|
["toolCallFailures", rollup.toolCallFailures, 0],
|
|
["evaluatorFailures", rollup.evaluatorFailures, 0],
|
|
["totalCostUsd", rollup.totalCostUsd, tolerance],
|
|
];
|
|
for (const [key, expected, metricTolerance] of checks) {
|
|
const actual = metricNumber(metrics, key, issues);
|
|
if (!nearEqual(actual, expected, metricTolerance)) {
|
|
pushIssue(
|
|
issues,
|
|
"error",
|
|
`$.metrics.${key}`,
|
|
`must equal stage rollup ${expected}; found ${actual}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
const finalDecision = metrics.finalDecision;
|
|
if (
|
|
finalDecision !== undefined &&
|
|
!["FINISH", "CONTINUE", "max_iterations", "error"].includes(
|
|
String(finalDecision),
|
|
)
|
|
) {
|
|
pushIssue(
|
|
issues,
|
|
"error",
|
|
"$.metrics.finalDecision",
|
|
"has an invalid value",
|
|
);
|
|
}
|
|
if (trajectory.status === "errored" && finalDecision !== "error") {
|
|
pushIssue(
|
|
issues,
|
|
"warning",
|
|
"$.metrics.finalDecision",
|
|
"errored trajectories should have finalDecision=error",
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Grade a trajectory's evidence live-vs-proxy-vs-mock from its model stages'
|
|
* recorded providers (#13623). A stage counts as a "model stage" when it
|
|
* carries a `model` record.
|
|
*/
|
|
export function deriveTrajectoryEvidenceGrade(
|
|
trajectory: RecordedTrajectory,
|
|
): TrajectoryEvidenceGrade {
|
|
let modelStages = 0;
|
|
let liveStages = 0;
|
|
let proxyStages = 0;
|
|
for (const stage of trajectory.stages ?? []) {
|
|
if (!stage.model) continue;
|
|
modelStages += 1;
|
|
const provider = stage.model.provider;
|
|
if (provider === DETERMINISTIC_LLM_PROXY_PROVIDER_NAME) {
|
|
proxyStages += 1;
|
|
} else if (!isNonLiveProviderName(provider)) {
|
|
liveStages += 1;
|
|
}
|
|
}
|
|
if (modelStages === 0) return "unknown";
|
|
if (liveStages > 0) return "live";
|
|
if (proxyStages > 0) return "proxy";
|
|
return "mock";
|
|
}
|
|
|
|
export function rollupTrajectory(
|
|
trajectory: RecordedTrajectory,
|
|
): TrajectoryRollup {
|
|
const rollup = cloneRollup();
|
|
for (const stage of trajectory.stages ?? []) {
|
|
rollup.totalLatencyMs += Number.isFinite(stage.latencyMs)
|
|
? stage.latencyMs
|
|
: 0;
|
|
if (stage.model) {
|
|
rollup.modelCallStages += 1;
|
|
if (stage.model.usage) {
|
|
rollup.totalPromptTokens += stage.model.usage.promptTokens ?? 0;
|
|
rollup.totalCompletionTokens += stage.model.usage.completionTokens ?? 0;
|
|
rollup.totalCacheReadTokens +=
|
|
stage.model.usage.cacheReadInputTokens ?? 0;
|
|
rollup.totalCacheCreationTokens +=
|
|
stage.model.usage.cacheCreationInputTokens ?? 0;
|
|
}
|
|
if (typeof stage.model.costUsd === "number") {
|
|
rollup.totalCostUsd += stage.model.costUsd;
|
|
} else if (stage.model.usage) {
|
|
rollup.totalCostUsd += computeCallCostUsd(
|
|
stage.model.modelName,
|
|
stage.model.usage,
|
|
);
|
|
}
|
|
}
|
|
if (stage.kind === "planner") rollup.plannerIterations += 1;
|
|
if (stage.kind === "tool") {
|
|
rollup.toolCallsExecuted += 1;
|
|
if (stage.tool?.success === false) {
|
|
rollup.toolCallFailures += 1;
|
|
rollup.toolResultFailures += 1;
|
|
} else if (stage.tool?.success === true) {
|
|
rollup.toolResultSuccesses += 1;
|
|
}
|
|
}
|
|
if (stage.kind === "evaluation") {
|
|
rollup.evaluatorStages += 1;
|
|
if (stage.evaluation?.success === false) rollup.evaluatorFailures += 1;
|
|
if (stage.evaluation?.success === true) rollup.evaluatorSuccesses += 1;
|
|
}
|
|
}
|
|
return rollup;
|
|
}
|
|
|
|
export function validateTrajectory(
|
|
value: unknown,
|
|
opts: ValidateTrajectoryOptions = {},
|
|
): TrajectoryValidationResult {
|
|
const issues: ValidationIssue[] = [];
|
|
const rollup = cloneRollup();
|
|
|
|
if (!isRecord(value)) {
|
|
pushIssue(issues, "error", "$", "must be an object");
|
|
return finalizeValidation(issues, rollup, [], []);
|
|
}
|
|
|
|
requireString(value.trajectoryId, issues, "$.trajectoryId", {
|
|
nonEmpty: true,
|
|
});
|
|
requireString(value.agentId, issues, "$.agentId", { nonEmpty: true });
|
|
if (value.roomId !== undefined)
|
|
requireString(value.roomId, issues, "$.roomId");
|
|
if (!isRecord(value.rootMessage)) {
|
|
pushIssue(issues, "error", "$.rootMessage", "must be an object");
|
|
} else {
|
|
requireString(value.rootMessage.id, issues, "$.rootMessage.id", {
|
|
nonEmpty: true,
|
|
});
|
|
requireString(value.rootMessage.text, issues, "$.rootMessage.text");
|
|
}
|
|
requireNumber(value.startedAt, issues, "$.startedAt", { min: 0 });
|
|
if (value.endedAt !== undefined)
|
|
requireNumber(value.endedAt, issues, "$.endedAt", { min: 0 });
|
|
if (!["running", "finished", "errored"].includes(String(value.status))) {
|
|
pushIssue(
|
|
issues,
|
|
"error",
|
|
"$.status",
|
|
"must be running, finished, or errored",
|
|
);
|
|
}
|
|
if (!Array.isArray(value.stages)) {
|
|
pushIssue(issues, "error", "$.stages", "must be an array");
|
|
return finalizeValidation(issues, rollup, [], []);
|
|
}
|
|
|
|
const stages = value.stages as RecordedStage[];
|
|
for (let i = 0; i < stages.length; i++) {
|
|
const stage = stages[i] as unknown;
|
|
const path = stagePath(i);
|
|
if (!isRecord(stage)) {
|
|
pushIssue(issues, "error", path, "must be an object");
|
|
continue;
|
|
}
|
|
requireString(stage.stageId, issues, `${path}.stageId`, { nonEmpty: true });
|
|
if (!STAGE_KINDS.has(stage.kind as RecordedStageKind)) {
|
|
pushIssue(issues, "error", `${path}.kind`, "has an invalid stage kind");
|
|
}
|
|
requireNumber(stage.startedAt, issues, `${path}.startedAt`, { min: 0 });
|
|
requireNumber(stage.endedAt, issues, `${path}.endedAt`, { min: 0 });
|
|
requireNumber(stage.latencyMs, issues, `${path}.latencyMs`, { min: 0 });
|
|
if (
|
|
typeof stage.startedAt === "number" &&
|
|
typeof stage.endedAt === "number" &&
|
|
typeof stage.latencyMs === "number" &&
|
|
stage.endedAt - stage.startedAt !== stage.latencyMs
|
|
) {
|
|
pushIssue(
|
|
issues,
|
|
"warning",
|
|
`${path}.latencyMs`,
|
|
"does not equal endedAt - startedAt",
|
|
);
|
|
}
|
|
|
|
const typedStage = stage as unknown as RecordedStage;
|
|
if (MODEL_STAGE_KINDS.has(typedStage.kind) && typedStage.kind !== "tool") {
|
|
validateModel(stage.model, typedStage, i, issues, opts);
|
|
}
|
|
if (typedStage.kind === "tool") {
|
|
if (!isRecord(stage.tool)) {
|
|
pushIssue(
|
|
issues,
|
|
"error",
|
|
`${path}.tool`,
|
|
"is required for tool stages",
|
|
);
|
|
} else {
|
|
requireString(stage.tool.name, issues, `${path}.tool.name`, {
|
|
nonEmpty: true,
|
|
});
|
|
if (!isRecord(stage.tool.args)) {
|
|
pushIssue(issues, "error", `${path}.tool.args`, "must be an object");
|
|
}
|
|
if (!("result" in stage.tool)) {
|
|
pushIssue(issues, "error", `${path}.tool.result`, "is required");
|
|
}
|
|
if (typeof stage.tool.success !== "boolean") {
|
|
pushIssue(
|
|
issues,
|
|
"error",
|
|
`${path}.tool.success`,
|
|
"must be a boolean",
|
|
);
|
|
}
|
|
requireNumber(
|
|
stage.tool.durationMs,
|
|
issues,
|
|
`${path}.tool.durationMs`,
|
|
{
|
|
min: 0,
|
|
},
|
|
);
|
|
}
|
|
}
|
|
if (typedStage.kind === "evaluation") {
|
|
if (!isRecord(stage.evaluation)) {
|
|
pushIssue(
|
|
issues,
|
|
"error",
|
|
`${path}.evaluation`,
|
|
"is required for evaluation stages",
|
|
);
|
|
} else {
|
|
if (typeof stage.evaluation.success !== "boolean") {
|
|
pushIssue(
|
|
issues,
|
|
"error",
|
|
`${path}.evaluation.success`,
|
|
"must be a boolean",
|
|
);
|
|
}
|
|
requireString(
|
|
stage.evaluation.decision,
|
|
issues,
|
|
`${path}.evaluation.decision`,
|
|
{
|
|
nonEmpty: true,
|
|
},
|
|
);
|
|
}
|
|
}
|
|
if (stage.cache !== undefined) {
|
|
if (!isRecord(stage.cache)) {
|
|
pushIssue(issues, "error", `${path}.cache`, "must be an object");
|
|
} else {
|
|
if (!Array.isArray(stage.cache.segmentHashes)) {
|
|
pushIssue(
|
|
issues,
|
|
"error",
|
|
`${path}.cache.segmentHashes`,
|
|
"must be an array",
|
|
);
|
|
}
|
|
requireString(
|
|
stage.cache.prefixHash,
|
|
issues,
|
|
`${path}.cache.prefixHash`,
|
|
{
|
|
nonEmpty: true,
|
|
},
|
|
);
|
|
}
|
|
} else if (
|
|
typedStage.kind === "planner" ||
|
|
typedStage.kind === "subPlanner"
|
|
) {
|
|
pushIssue(
|
|
issues,
|
|
"warning",
|
|
`${path}.cache`,
|
|
"is missing; cache segment diffs cannot be reviewed",
|
|
);
|
|
}
|
|
}
|
|
|
|
const trajectory = value as unknown as RecordedTrajectory;
|
|
const computedRollup = rollupTrajectory(trajectory);
|
|
validateStageSequence(trajectory.stages, issues, opts.expectedStages);
|
|
validateMetrics(trajectory, computedRollup, issues, opts);
|
|
const selectedContexts = extractSelectedContexts(trajectory.stages, issues);
|
|
validateContextEvidence(trajectory.stages, selectedContexts, issues, opts);
|
|
|
|
const evidenceGrade = deriveTrajectoryEvidenceGrade(trajectory);
|
|
if (opts.requireLiveProvider && evidenceGrade !== "live") {
|
|
pushIssue(
|
|
issues,
|
|
"error",
|
|
"$.stages[].model.provider",
|
|
`requireLiveProvider: trajectory evidence grade is "${evidenceGrade}", not "live" — every model stage was served by the deterministic proxy, "default", or an empty provider`,
|
|
);
|
|
}
|
|
|
|
return finalizeValidation(
|
|
issues,
|
|
computedRollup,
|
|
selectedContexts,
|
|
trajectory.stages.map((stage) => stage.kind),
|
|
evidenceGrade,
|
|
);
|
|
}
|
|
|
|
function finalizeValidation(
|
|
issues: ValidationIssue[],
|
|
rollup: TrajectoryRollup,
|
|
selectedContexts: string[],
|
|
stageKinds: string[],
|
|
evidenceGrade: TrajectoryEvidenceGrade = "unknown",
|
|
): TrajectoryValidationResult {
|
|
const errorCount = issues.filter(
|
|
(issue) => issue.severity === "error",
|
|
).length;
|
|
const warningCount = issues.filter(
|
|
(issue) => issue.severity === "warning",
|
|
).length;
|
|
return {
|
|
ok: errorCount === 0,
|
|
errorCount,
|
|
warningCount,
|
|
issues,
|
|
rollup,
|
|
selectedContexts,
|
|
stageKinds,
|
|
evidenceGrade,
|
|
};
|
|
}
|
|
|
|
export function validateTrajectoryJsonReport(
|
|
body: string,
|
|
opts: ValidateTrajectoryOptions = {},
|
|
): TrajectoryValidationResult {
|
|
try {
|
|
return validateTrajectory(JSON.parse(body), opts);
|
|
} catch (err) {
|
|
const rollup = cloneRollup();
|
|
return finalizeValidation(
|
|
[
|
|
{
|
|
severity: "error",
|
|
path: "$",
|
|
message: `invalid JSON report: ${(err as Error).message}`,
|
|
},
|
|
],
|
|
rollup,
|
|
[],
|
|
[],
|
|
);
|
|
}
|
|
}
|
|
|
|
export function validateTrajectoryMarkdownReport(
|
|
body: string,
|
|
trajectory: RecordedTrajectory,
|
|
): TrajectoryValidationResult {
|
|
const issues: ValidationIssue[] = [];
|
|
const rollup = rollupTrajectory(trajectory);
|
|
if (!body.includes(`# Trajectory ${trajectory.trajectoryId}`)) {
|
|
pushIssue(issues, "error", "$.markdown", "missing trajectory heading");
|
|
}
|
|
if (!body.includes(`root message:`)) {
|
|
pushIssue(issues, "error", "$.markdown", "missing root message");
|
|
}
|
|
for (const [idx, stage] of trajectory.stages.entries()) {
|
|
const heading = `## Stage ${idx + 1}: ${stage.kind}`;
|
|
const headingIndex = body.indexOf(heading);
|
|
if (headingIndex < 0) {
|
|
pushIssue(
|
|
issues,
|
|
"error",
|
|
`$.markdown.stages[${idx}]`,
|
|
"missing stage heading",
|
|
);
|
|
}
|
|
const nextHeadingIndex = body.indexOf("\n## Stage ", headingIndex + 1);
|
|
const stageBody =
|
|
headingIndex >= 0
|
|
? body.slice(
|
|
headingIndex,
|
|
nextHeadingIndex >= 0 ? nextHeadingIndex : undefined,
|
|
)
|
|
: "";
|
|
if (stage.model) {
|
|
for (const label of [
|
|
"PROMPT:",
|
|
"RESPONSE:",
|
|
"MESSAGES:",
|
|
"TOOLS:",
|
|
"TOOL_CALLS:",
|
|
]) {
|
|
if (!stageBody.includes(label)) {
|
|
pushIssue(
|
|
issues,
|
|
"error",
|
|
`$.markdown.stages[${idx}]`,
|
|
`missing ${label} block`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
if (stage.tool && !stageBody.includes(`tool \`${stage.tool.name}\``)) {
|
|
pushIssue(
|
|
issues,
|
|
"error",
|
|
`$.markdown.stages[${idx}]`,
|
|
"missing tool result block",
|
|
);
|
|
}
|
|
if (stage.evaluation && !stageBody.includes("evaluation:")) {
|
|
pushIssue(
|
|
issues,
|
|
"error",
|
|
`$.markdown.stages[${idx}]`,
|
|
"missing evaluation block",
|
|
);
|
|
}
|
|
}
|
|
return finalizeValidation(
|
|
issues,
|
|
rollup,
|
|
extractSelectedContexts(trajectory.stages, issues),
|
|
trajectory.stages.map((stage) => stage.kind),
|
|
deriveTrajectoryEvidenceGrade(trajectory),
|
|
);
|
|
}
|
|
|
|
export function validateTrajectoryExportReport(
|
|
format: "markdown" | "json",
|
|
body: string,
|
|
trajectory: RecordedTrajectory,
|
|
): TrajectoryValidationResult {
|
|
if (format === "json") return validateTrajectoryJsonReport(body);
|
|
return validateTrajectoryMarkdownReport(body, trajectory);
|
|
}
|
|
|
|
export function compareTrajectories(
|
|
a: RecordedTrajectory,
|
|
b: RecordedTrajectory,
|
|
): CompareTrajectorySummary {
|
|
const rollupA = rollupTrajectory(a);
|
|
const rollupB = rollupTrajectory(b);
|
|
const delta: TrajectoryRollup = {
|
|
totalLatencyMs: numberDelta(rollupA.totalLatencyMs, rollupB.totalLatencyMs),
|
|
totalPromptTokens: numberDelta(
|
|
rollupA.totalPromptTokens,
|
|
rollupB.totalPromptTokens,
|
|
),
|
|
totalCompletionTokens: numberDelta(
|
|
rollupA.totalCompletionTokens,
|
|
rollupB.totalCompletionTokens,
|
|
),
|
|
totalCacheReadTokens: numberDelta(
|
|
rollupA.totalCacheReadTokens,
|
|
rollupB.totalCacheReadTokens,
|
|
),
|
|
totalCacheCreationTokens: numberDelta(
|
|
rollupA.totalCacheCreationTokens,
|
|
rollupB.totalCacheCreationTokens,
|
|
),
|
|
totalCostUsd: numberDelta(rollupA.totalCostUsd, rollupB.totalCostUsd),
|
|
plannerIterations: numberDelta(
|
|
rollupA.plannerIterations,
|
|
rollupB.plannerIterations,
|
|
),
|
|
toolCallsExecuted: numberDelta(
|
|
rollupA.toolCallsExecuted,
|
|
rollupB.toolCallsExecuted,
|
|
),
|
|
toolCallFailures: numberDelta(
|
|
rollupA.toolCallFailures,
|
|
rollupB.toolCallFailures,
|
|
),
|
|
evaluatorStages: numberDelta(
|
|
rollupA.evaluatorStages,
|
|
rollupB.evaluatorStages,
|
|
),
|
|
evaluatorSuccesses: numberDelta(
|
|
rollupA.evaluatorSuccesses,
|
|
rollupB.evaluatorSuccesses,
|
|
),
|
|
evaluatorFailures: numberDelta(
|
|
rollupA.evaluatorFailures,
|
|
rollupB.evaluatorFailures,
|
|
),
|
|
modelCallStages: numberDelta(
|
|
rollupA.modelCallStages,
|
|
rollupB.modelCallStages,
|
|
),
|
|
toolResultSuccesses: numberDelta(
|
|
rollupA.toolResultSuccesses,
|
|
rollupB.toolResultSuccesses,
|
|
),
|
|
toolResultFailures: numberDelta(
|
|
rollupA.toolResultFailures,
|
|
rollupB.toolResultFailures,
|
|
),
|
|
};
|
|
return {
|
|
idA: a.trajectoryId,
|
|
idB: b.trajectoryId,
|
|
a: rollupA,
|
|
b: rollupB,
|
|
delta,
|
|
cacheHitRateA: cacheHitRate(rollupA),
|
|
cacheHitRateB: cacheHitRate(rollupB),
|
|
cacheHitRateDelta: cacheHitRate(rollupB) - cacheHitRate(rollupA),
|
|
estimatedBatchingDelta: {
|
|
modelCalls: delta.modelCallStages,
|
|
plannerIterations: delta.plannerIterations,
|
|
stages: b.stages.length - a.stages.length,
|
|
toolCalls: delta.toolCallsExecuted,
|
|
},
|
|
};
|
|
}
|
|
|
|
export function cacheHitRate(rollup: TrajectoryRollup): number {
|
|
if (rollup.totalPromptTokens <= 0) return 0;
|
|
return rollup.totalCacheReadTokens / rollup.totalPromptTokens;
|
|
}
|