Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 124 additions & 0 deletions src/core/eval.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ import {
} from "@aws-sdk/client-cloudwatch-logs";
import type { DocumentType } from "@smithy/types";
import { randomUUID } from "node:crypto";
import { unlink } from "node:fs/promises";
import { tmpdir } from "node:os";
import { basename, dirname, extname, join } from "node:path";
import { Transform } from "node:stream";
import { setTimeout as sleep } from "node:timers/promises";
Expand Down Expand Up @@ -108,12 +110,19 @@ import type {
LlmAsAJudgeUpdate,
SessionSourceValue,
SessionTrace,
InvokeDatasetInput,
InvokeDatasetResult,
SpanRecord,
StartBatchEvaluationInput,
UpdateConfigurationBundleInput,
UpdateOnlineEvalInput,
} from "../handlers/eval/types";
import { atomicWrite, atomicWriteStream, readTextFile } from "../io";
import { accountIdFromRuntimeArn, invokeRuntime } from "./invokeRuntime";
import { DatasetLoader } from "./eval/invokeDataset/load";
import { runExamples } from "./eval/invokeDataset/run";
import { renderJsonTemplate } from "./eval/invokeDataset/template";
import type { RunContext } from "./eval/invokeDataset/example/types";
import { isTerminalStatus, readEvaluationResults } from "./batchEvaluationResults";
import { applyExampleIds, diffExamples, indexRemoteById, parseJsonl } from "./datasetDiff";
import type { Addition } from "./datasetDiff";
Expand Down Expand Up @@ -507,6 +516,121 @@ export class EvalClient implements CoreEvalClient {
};
}

async invokeDataset(
input: InvokeDatasetInput,
options: CoreOptions,
signal?: AbortSignal,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe OOS here, but should we move the signal inside options? I would think all core clients would care about cancellations.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah i would think so too, I'll add it as a follow up PR.

): Promise<InvokeDatasetResult> {
// A template with no {input} sends the same payload for every turn, ignoring the
// scenario — always a mistake. Fail before reading the dataset or invoking anything.
if (!input.payloadTemplate.includes("{input}")) {
throw new InputValidationError("--payload-template must contain the {input} placeholder");
}
const examples = DatasetLoader.load(
await this.readDatasetText(input.dataset, input.datasetVersion, options, signal),
);

// Resolve the runtime once, reused for every session.
const runtime = await this.clients
.control(toClientConfig(options))
.send(new GetAgentRuntimeCommand({ agentRuntimeId: input.runtimeId }), {
abortSignal: signal,
});
const deps = { clients: this.clients, fetch: this.fetch, logger: this.logger };
const accountId = accountIdFromRuntimeArn(runtime.agentRuntimeArn);

const { ok, failed, firstError } = await runExamples(examples, async (example) => {
// One session per example; the id is a client-owned input per the AgentCore docs,
// reused across turns so the conversation and its per-turn traces stay in order.
const sessionId = randomUUID();
const ctx: RunContext = {
invokeOnce: async (payload) => {
const response = await invokeRuntime(
deps,
{
runtimeId: input.runtimeId,
accountId,
qualifier: input.qualifier ?? DEFAULT_ENDPOINT_QUALIFIER,
payload: renderJsonTemplate(input.payloadTemplate, { input: payload }),
contentType: "application/json",
accept: "application/json",
...(input.headers?.length ? { applicationHeaders: input.headers } : {}),
...(input.bearerToken !== undefined ? { bearerToken: input.bearerToken } : {}),
runtimeSessionId: sessionId,
runtimeUserId: input.userId,
},
options,
signal,
);
// Read to completion to free the socket; a scripted example ignores the text.
let text = "";
const decoder = new TextDecoder();
for await (const chunk of response.body) text += decoder.decode(chunk, { stream: true });
text += decoder.decode();
return { text };
},
};
try {
const groundTruth = await example.run(ctx);
return { exampleId: example.exampleId, sessionId, groundTruth };
} catch (error) {
// Enrich with the example identity so the dropped-invoke reason is self-describing
// in firstError, instead of a bare transport message logged separately.
const cause = error instanceof Error ? error : new Error(String(error));
throw new Error(
`example "${example.exampleId}" (${example.schemaType}) failed to invoke: ${cause.message}`,
{ cause },
);
}
});

if (failed > 0) {
this.logger.warn(`invokeDataset: ${failed} example(s) failed to invoke and were dropped`);
}

// AgentCore emits spans ~30s-3min after invoke; grade too early and it reads an empty
// log group and fails every session. Disabled via SIMULATE_INGESTION_WAIT_MS=0 (tests).
const waitMs = Number(process.env.SIMULATE_INGESTION_WAIT_MS ?? 180_000);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there anything we can poll on instead of a static wait time?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't poll right away because some traces might still be arriving, and I didn't want us to end up with incomplete session data.

if (ok.length > 0 && waitMs > 0) {
this.logger.info(
`waiting ${Math.round(waitMs / 1000)}s for span ingestion before evaluating`,
);
await sleep(waitMs, undefined, { signal });
}

return { sessions: ok, invoked: ok.length, failed, firstError };
}

// Resolve a dataset ref to JSONL text: a local path directly, else download the id to a
// temp file (cleaned up here). Reuses readLocalDatasetFile so replay reads like update.
private async readDatasetText(
ref: string,
version: string | undefined,
options: CoreOptions,
signal?: AbortSignal,
): Promise<string> {
if (await Bun.file(ref).exists()) return readLocalDatasetFile(ref, signal);
const path = await this.downloadDatasetToTemp(ref, version, options, signal);
try {
return await readLocalDatasetFile(path, signal);
} finally {
await unlink(path).catch(() => {});
}
}

// downloadDatasetToTemp streams a dataset version's JSONL to a temp file so
// readDatasetText can read it — reuses downloadDataset rather than re-fetching.
private async downloadDatasetToTemp(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would it be simpler to inline this above? The extra step adds some unnecessary complexity imo.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sure we can

id: string,
version: string | undefined,
options: CoreOptions,
signal?: AbortSignal,
): Promise<string> {
const path = join(tmpdir(), `agentcore-dataset-${randomUUID()}.jsonl`);
await this.downloadDataset(id, version, path, options, signal);
return path;
}

async createOnlineEvaluationConfig(
input: CreateOnlineEvalInput,
options: CoreOptions,
Expand Down
172 changes: 172 additions & 0 deletions src/core/eval/invokeDataset/__snapshots__/invokeDataset.test.ts.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
// Bun Snapshot v1, https://bun.sh/docs/test/snapshots

exports[`EvalClient.invokeDataset golden: sessions + ground truth over representative datasets 1`] = `
{
"assertions + trajectory + sparse turns (full inline shape)": {
"failed": 0,
"invoked": 1,
"sessions": [
{
"exampleId": "orders-1",
"groundTruth": {
"assertions": [
{
"text": "stays polite",
},
{
"text": "does not promise a date",
},
],
"expectedTrajectory": {
"toolNames": [
"refund_lookup",
"refund_create",
],
},
"turns": [
{
"input": {
"prompt": "I want a refund",
},
},
{
"expectedResponse": {
"text": "Refund started",
},
"input": {
"prompt": "order 123",
},
},
],
},
"sessionId": "<uuid>",
},
],
},
"empty assertions/trajectory arrays are omitted": {
"failed": 0,
"invoked": 1,
"sessions": [
{
"exampleId": "e4",
"groundTruth": {
"turns": [
{
"expectedResponse": {
"text": "r1",
},
"input": {
"prompt": "t1",
},
},
],
},
"sessionId": "<uuid>",
},
],
},
"empty expected_response is treated as no expectation": {
"failed": 0,
"invoked": 1,
"sessions": [
{
"exampleId": "e3",
"groundTruth": undefined,
"sessionId": "<uuid>",
},
],
},
"legacy scenario_id fallback + unicode id": {
"failed": 0,
"invoked": 1,
"sessions": [
{
"exampleId": "café-日本-🎉",
"groundTruth": {
"turns": [
{
"expectedResponse": {
"text": "ok",
},
"input": {
"prompt": "1",
},
},
],
},
"sessionId": "<uuid>",
},
],
},
"multi-turn, sparse expectation keeps its turn position": {
"failed": 0,
"invoked": 1,
"sessions": [
{
"exampleId": "e2",
"groundTruth": {
"turns": [
{
"input": {
"prompt": "t1",
},
},
{
"input": {
"prompt": "t2",
},
},
{
"expectedResponse": {
"text": "42",
},
"input": {
"prompt": "t3",
},
},
],
},
"sessionId": "<uuid>",
},
],
},
"single turn, no ground truth": {
"failed": 0,
"invoked": 1,
"sessions": [
{
"exampleId": "e1",
"groundTruth": undefined,
"sessionId": "<uuid>",
},
],
},
"tolerates blank lines and CRLF between multiple rows": {
"failed": 0,
"invoked": 2,
"sessions": [
{
"exampleId": "a",
"groundTruth": undefined,
"sessionId": "<uuid>",
},
{
"exampleId": "b",
"groundTruth": {
"turns": [
{
"expectedResponse": {
"text": "ok",
},
"input": {
"prompt": "2",
},
},
],
},
"sessionId": "<uuid>",
},
],
},
}
`;
Loading
Loading