Skip to content

Commit 1b4b5a0

Browse files
author
jariy17
committed
feat(eval): add batch-evaluation evaluate + ondemand evaluate
Adds the write path for evaluating existing sessions: - `agentcore eval batch-evaluation evaluate`: async, service-side (StartBatchEvaluation). Source is one of --agent (harness/runtime id, resolved to a CloudWatch data source), --online-eval, or a raw --data-source-config JSON escape hatch, narrowed by --lookback-days / --start-time+--end-time / --session-ids. Returns a durable job id; poll with the existing `get`. - `agentcore eval ondemand evaluate`: synchronous, client-side. Gathers the target sessions' OTel spans from CloudWatch (ported from the old CLI's two-phase span collector — spans + runtime log records) and calls the Evaluate API per session, printing scores. No job is created. Shared source flags + resolution live in eval/sessionSource.tsx (SESSION_SOURCE_FLAGS / BATCH_SOURCE_FLAGS / resolveSessionSource), following the llm-as-a-judge/sharedFlags pattern. Verified end-to-end against a live account (batch job COMPLETED 5/5; ondemand aggregate 0.86). Tests: resolveSessionSource arm/filter validation + Core dataSourceConfig mapping (agent/online-eval/raw arms, ground truth).
1 parent b8e7f9b commit 1b4b5a0

12 files changed

Lines changed: 992 additions & 5 deletions

File tree

src/core/eval.tsx

Lines changed: 298 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,19 @@ import {
4040
import {
4141
GetBatchEvaluationCommand,
4242
ListBatchEvaluationsCommand,
43+
StartBatchEvaluationCommand,
44+
EvaluateCommand,
4345
type ListBatchEvaluationsResponse,
46+
type StartBatchEvaluationResponse,
47+
type DataSourceConfig as DataPlaneDataSourceConfig,
48+
type CloudWatchFilterConfig,
4449
} from "@aws-sdk/client-bedrock-agentcore";
50+
import {
51+
StartQueryCommand,
52+
GetQueryResultsCommand,
53+
type ResultField,
54+
} from "@aws-sdk/client-cloudwatch-logs";
55+
import type { DocumentType } from "@smithy/types";
4556
import { Transform } from "node:stream";
4657
import { FileWriteError, InputValidationError, NetworkingError } from "../errors";
4758
import type {
@@ -53,8 +64,14 @@ import type {
5364
CreateOnlineEvalInput,
5465
GetBatchEvaluationResult,
5566
LlmAsAJudgeUpdate,
67+
OnDemandEvaluateInput,
68+
OnDemandEvaluateResult,
69+
OnDemandEvaluateScore,
70+
StartBatchEvaluationInput,
5671
UpdateOnlineEvalInput,
5772
} from "../handlers/eval/types";
73+
import type { SessionSourceValue, SessionWindow } from "../handlers/eval/sessionSource";
74+
import { toSessionFilter } from "../handlers/eval/sessionSource";
5875
import { atomicWriteStream } from "../io";
5976
import { isTerminalStatus, readEvaluationResults } from "./batchEvaluationResults";
6077
import type { AwsClients, CoreFetch, CoreOptions } from "./types";
@@ -280,6 +297,114 @@ export class EvalClient implements CoreEvalClient {
280297
.send(new ListBatchEvaluationsCommand({ nextToken, maxResults }));
281298
}
282299

300+
// startBatchEvaluation submits the async, service-side job. Core translates the
301+
// resolved SessionSourceValue into the dataSourceConfig union: the agent arm
302+
// resolves the harness/runtime id to a log group (reusing agentDataSource), the
303+
// online-eval arm points at a config ARN, and the raw arm passes JSON through.
304+
async startBatchEvaluation(
305+
input: StartBatchEvaluationInput,
306+
options: CoreOptions,
307+
): Promise<StartBatchEvaluationResponse> {
308+
const dataSourceConfig = await this.dataSourceConfigForSource(input.source, options);
309+
return this.clients.data(toClientConfig(options)).send(
310+
new StartBatchEvaluationCommand({
311+
batchEvaluationName: input.name,
312+
description: input.description,
313+
evaluators: input.evaluatorIds.map((evaluatorId) => ({ evaluatorId })),
314+
dataSourceConfig,
315+
evaluationMetadata: input.groundTruth ? { sessionMetadata: input.groundTruth } : undefined,
316+
kmsKeyArn: input.kmsKeyArn,
317+
}),
318+
);
319+
}
320+
321+
// dataSourceConfigForSource maps a resolved SessionSourceValue to the data-plane
322+
// dataSourceConfig union. The agent arm reuses the same runtime resolution +
323+
// log-group derivation the control-plane agentDataSource uses, then attaches the
324+
// session-id / time-range filters; the raw arm is returned verbatim.
325+
private async dataSourceConfigForSource(
326+
source: SessionSourceValue,
327+
options: CoreOptions,
328+
): Promise<DataPlaneDataSourceConfig> {
329+
if (source.origin === "raw") return source.dataSourceConfig;
330+
331+
const timeRange = toSessionFilter(source.window);
332+
333+
if (source.origin === "online-eval") {
334+
return {
335+
onlineEvaluationConfigSource: {
336+
onlineEvaluationConfigArn: source.onlineEvaluationConfigId,
337+
timeRange,
338+
},
339+
};
340+
}
341+
342+
const qualifier = source.endpoint ?? DEFAULT_ENDPOINT_QUALIFIER;
343+
const { runtimeId, runtimeName } = await resolveAgentToRuntime(
344+
source.agent,
345+
this.clients,
346+
options,
347+
);
348+
const filterConfig: CloudWatchFilterConfig | undefined =
349+
source.sessionIds || timeRange ? { sessionIds: source.sessionIds, timeRange } : undefined;
350+
return {
351+
cloudWatchLogs: {
352+
logGroupNames: [runtimeLogGroup(runtimeId, qualifier)],
353+
serviceNames: [runtimeServiceName(runtimeName, qualifier)],
354+
filterConfig,
355+
},
356+
};
357+
}
358+
359+
// evaluateOnDemand runs the synchronous, client-side path: it queries CloudWatch
360+
// for the target sessions' OTel spans itself, then calls the Evaluate API once
361+
// per (evaluator, session) with the collected spans. Ported from the old CLI's
362+
// fetchSessionSpans + runEvaluatorsOverSessions. No job is created.
363+
async evaluateOnDemand(
364+
input: OnDemandEvaluateInput,
365+
options: CoreOptions,
366+
): Promise<OnDemandEvaluateResult> {
367+
const qualifier = input.endpoint ?? DEFAULT_ENDPOINT_QUALIFIER;
368+
const { runtimeId } = await resolveAgentToRuntime(input.agent, this.clients, options);
369+
const logGroup = runtimeLogGroup(runtimeId, qualifier);
370+
const logs = this.clients.logs({ region: options.region });
371+
372+
const sessions = await fetchSessionSpans(logs, {
373+
runtimeId,
374+
runtimeLogGroup: logGroup,
375+
window: input.window,
376+
sessionIds: input.sessionIds,
377+
});
378+
if (sessions.length === 0) {
379+
throw new InputValidationError(
380+
`No sessions with evaluable spans were found for agent "${input.agent}". ` +
381+
`Widen --lookback-days / the time window, or check --session-ids.`,
382+
{ meta: { agent: input.agent } },
383+
);
384+
}
385+
386+
const data = this.clients.data(toClientConfig(options));
387+
const scores: OnDemandEvaluateScore[] = [];
388+
for (const evaluatorId of input.evaluatorIds) {
389+
const results = [];
390+
for (const session of sessions) {
391+
const response = await data.send(
392+
new EvaluateCommand({
393+
evaluatorId,
394+
evaluationInput: { sessionSpans: session.spans },
395+
}),
396+
);
397+
results.push(...(response.evaluationResults ?? []));
398+
}
399+
const numeric = results.map((r) => r.value).filter((v): v is number => typeof v === "number");
400+
const aggregateScore =
401+
numeric.length > 0 ? numeric.reduce((sum, v) => sum + v, 0) / numeric.length : 0;
402+
scores.push({ evaluatorId, aggregateScore, results });
403+
}
404+
405+
return { sessionsEvaluated: sessions.length, scores };
406+
}
407+
283408
async createOnlineEvaluationConfig(
284409
input: CreateOnlineEvalInput,
285410
options: CoreOptions,
@@ -666,6 +791,179 @@ function runtimeLogGroup(runtimeId: string, endpoint: string): string {
666791
return `/aws/bedrock-agentcore/runtimes/${runtimeId}-${endpoint}`;
667792
}
668793

794+
// --- on-demand client-side span collection (ported from the old CLI's
795+
// operations/eval/shared/span-collector.ts) ---
796+
797+
const SPANS_LOG_GROUP = "aws/spans";
798+
799+
// Instrumentation scopes / log records the Evaluate API understands. A runtime
800+
// log record with body.input/body.output carries the conversation turn text.
801+
const SUPPORTED_SCOPES = new Set([
802+
"strands.telemetry.tracer",
803+
"opentelemetry.instrumentation.langchain",
804+
"openinference.instrumentation.langchain",
805+
]);
806+
807+
type CollectedSession = { sessionId: string; spans: DocumentType[] };
808+
809+
type FetchSpansOptions = {
810+
runtimeId: string;
811+
runtimeLogGroup: string;
812+
window?: SessionWindow;
813+
sessionIds?: string[];
814+
};
815+
816+
// sanitizeQueryValue strips single quotes so an id can't break out of a CloudWatch
817+
// Insights query string literal.
818+
function sanitizeQueryValue(value: string): string {
819+
return value.replace(/'/g, "");
820+
}
821+
822+
// runCwQuery runs a CloudWatch Logs Insights query and waits for completion,
823+
// returning [] if the log group does not exist yet.
824+
async function runCwQuery(
825+
logs: ReturnType<AwsClients["logs"]>,
826+
logGroupName: string,
827+
queryString: string,
828+
startTimeSec: number,
829+
endTimeSec: number,
830+
): Promise<ResultField[][]> {
831+
let queryId: string | undefined;
832+
try {
833+
const started = await logs.send(
834+
new StartQueryCommand({
835+
logGroupName,
836+
startTime: startTimeSec,
837+
endTime: endTimeSec,
838+
queryString,
839+
}),
840+
);
841+
queryId = started.queryId;
842+
} catch (error) {
843+
const name = (error as { name?: string })?.name;
844+
if (name === "ResourceNotFoundException") return [];
845+
throw error;
846+
}
847+
if (!queryId) return [];
848+
849+
for (let i = 0; i < 60; i++) {
850+
await new Promise((resolve) => setTimeout(resolve, 1000));
851+
const res = await logs.send(new GetQueryResultsCommand({ queryId }));
852+
const status = res.status ?? "Unknown";
853+
if (status === "Failed" || status === "Cancelled") {
854+
throw new NetworkingError(`CloudWatch query ${status.toLowerCase()}`);
855+
}
856+
if (status === "Complete") return res.results ?? [];
857+
}
858+
throw new NetworkingError("CloudWatch query timed out after 60 seconds");
859+
}
860+
861+
// fetchSessionSpans queries the shared `aws/spans` log group (and the runtime's
862+
// own log group) for the agent's OTel spans, grouping them by session id. The
863+
// Evaluate API takes one session's spans per call. Time window and specific
864+
// session ids narrow the query. Ported from the old CLI's fetchSessionSpans.
865+
async function fetchSessionSpans(
866+
logs: ReturnType<AwsClients["logs"]>,
867+
opts: FetchSpansOptions,
868+
): Promise<CollectedSession[]> {
869+
const filter = toSessionFilter(opts.window);
870+
const endTimeMs = filter ? +filter.endTime : Date.now();
871+
// Default to a 30-day window when the caller gave no time filter, so the query
872+
// is bounded rather than scanning all retained logs.
873+
const startTimeMs = filter ? +filter.startTime : endTimeMs - 30 * 24 * 60 * 60 * 1000;
874+
const startTimeSec = Math.floor(startTimeMs / 1000);
875+
const endTimeSec = Math.floor(endTimeMs / 1000);
876+
877+
let spanQuery =
878+
`fields @message, attributes.session.id as sessionId, traceId\n` +
879+
` | parse resource.attributes.cloud.resource_id "runtime/*/" as parsedAgentId\n` +
880+
` | filter parsedAgentId = '${sanitizeQueryValue(opts.runtimeId)}'\n` +
881+
` | filter ispresent(scope.name) and ispresent(kind)`;
882+
if (opts.sessionIds && opts.sessionIds.length > 0) {
883+
const ids = opts.sessionIds.map((s) => `'${sanitizeQueryValue(s)}'`).join(", ");
884+
spanQuery += `\n | filter attributes.session.id in [${ids}]`;
885+
}
886+
spanQuery += `\n | sort startTimeUnixNano asc\n | limit 10000`;
887+
888+
const [sharedRows, runtimeRows] = await Promise.all([
889+
runCwQuery(logs, SPANS_LOG_GROUP, spanQuery, startTimeSec, endTimeSec),
890+
runCwQuery(logs, opts.runtimeLogGroup, spanQuery, startTimeSec, endTimeSec),
891+
]);
892+
const allSpanRows = [...sharedRows, ...runtimeRows];
893+
894+
// Phase 1: group the OTel spans by session and collect their trace ids. Keep
895+
// every span doc — the Evaluate API needs full trace context, so we do NOT
896+
// filter these (only the runtime log records added in phase 2 are filtered).
897+
const sessionMap = new Map<string, DocumentType[]>();
898+
const traceToSession = new Map<string, string>();
899+
const traceIds = new Set<string>();
900+
for (const row of allSpanRows) {
901+
const message = row.find((f) => f.field === "@message")?.value;
902+
const sessionId = row.find((f) => f.field === "sessionId")?.value ?? "unknown";
903+
const traceId = row.find((f) => f.field === "traceId")?.value;
904+
if (!message) continue;
905+
let doc: Record<string, unknown>;
906+
try {
907+
doc = JSON.parse(message) as Record<string, unknown>;
908+
} catch {
909+
continue;
910+
}
911+
if (!sessionMap.has(sessionId)) sessionMap.set(sessionId, []);
912+
sessionMap.get(sessionId)!.push(doc as DocumentType);
913+
if (traceId) {
914+
traceIds.add(traceId);
915+
traceToSession.set(traceId, sessionId);
916+
}
917+
}
918+
919+
if (sessionMap.size === 0) return [];
920+
921+
// Phase 2: the spans reference conversation turns whose text lives in the
922+
// runtime's own log records (body.input / body.output). Without them the
923+
// Evaluate API reports LogEventMissingException, so pull the log records for the
924+
// discovered trace ids and attach them to their session.
925+
if (traceIds.size > 0) {
926+
const traceFilter = [...traceIds].map((t) => `'${sanitizeQueryValue(t)}'`).join(", ");
927+
const logRows = await runCwQuery(
928+
logs,
929+
opts.runtimeLogGroup,
930+
`fields @message, traceId\n` +
931+
` | filter traceId in [${traceFilter}]\n` +
932+
` | sort @timestamp asc\n | limit 10000`,
933+
startTimeSec,
934+
endTimeSec,
935+
);
936+
for (const row of logRows) {
937+
const message = row.find((f) => f.field === "@message")?.value;
938+
const traceId = row.find((f) => f.field === "traceId")?.value;
939+
if (!message) continue;
940+
let doc: Record<string, unknown>;
941+
try {
942+
doc = JSON.parse(message) as Record<string, unknown>;
943+
} catch {
944+
continue;
945+
}
946+
if (!isRelevantForEval(doc)) continue;
947+
const sessionId = traceId ? (traceToSession.get(traceId) ?? "unknown") : "unknown";
948+
if (!sessionMap.has(sessionId)) sessionMap.set(sessionId, []);
949+
sessionMap.get(sessionId)!.push(doc as DocumentType);
950+
}
951+
}
952+
953+
return [...sessionMap]
954+
.filter(([, spans]) => spans.length > 0)
955+
.map(([sessionId, spans]) => ({ sessionId, spans }));
956+
}
957+
958+
// isRelevantForEval keeps spans the Evaluate API can score: a supported
959+
// instrumentation scope, or a runtime log record carrying conversation turn text.
960+
function isRelevantForEval(doc: Record<string, unknown>): boolean {
961+
const scopeName = (doc.scope as Record<string, unknown> | undefined)?.name as string | undefined;
962+
if (scopeName && SUPPORTED_SCOPES.has(scopeName)) return true;
963+
const body = doc.body;
964+
return !!body && typeof body === "object" && ("input" in body || "output" in body);
965+
}
966+
669967
// runtimeServiceName derives the CloudWatch trace service name that scopes a
670968
// CreateOnlineEvaluationConfig data source to one runtime endpoint's sessions:
671969
// `{runtimeName}.{endpoint}`, keyed by the runtime *name* (verified against

0 commit comments

Comments
 (0)