Skip to content
Merged
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
39 changes: 39 additions & 0 deletions .changeset/soft-dots-bow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
---
"@voltagent/evals": patch
---

fix: persist offline eval runs when using inline datasets (`dataset.items`)

Offline experiment runs now create and sync run results even when the dataset is provided inline without a managed `datasetVersionId`.

### What changed

- `VoltOpsRunManager` now allows run creation when `dataset.versionId` is missing.
- Append payload generation now normalizes `datasetItemId`:
- UUID item IDs are sent as `datasetItemId`.
- Non-UUID item IDs are sent as `null` and still tracked via `datasetItemHash`.

This avoids API failures when inline dataset items use string IDs like `"item-1"`.

### Example

```ts
import { createExperiment, runExperiment } from "@voltagent/evals";

const experiment = createExperiment({
dataset: {
name: "inline-smoke",
items: [
{
id: "item-1", // non-UUID is supported
input: "What is VoltAgent?",
expected: "An open-source TypeScript framework for AI agents.",
},
],
},
runner: async ({ item }) => ({ output: String(item.input) }),
});

const result = await runExperiment(experiment, { voltOpsClient });
console.log(result.runId); // now created and persisted
```
2 changes: 1 addition & 1 deletion examples/with-chat-sdk/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
{
"name": "voltagent-example-with-chat-sdk",
"version": "0.1.0",
"private": true,
"author": "",
"dependencies": {
"@chat-adapter/slack": "^4.14.0",
Expand Down Expand Up @@ -29,6 +28,7 @@
"voltagent"
],
"license": "MIT",
"private": true,
"repository": {
"type": "git",
"url": "https://github.com/VoltAgent/voltagent.git",
Expand Down
8 changes: 5 additions & 3 deletions packages/evals/src/experiment/run-experiment.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,19 @@ import type { ExperimentDatasetItem } from "./types.js";

const DATASET_ID = "dataset-integration";
const DATASET_VERSION_ID = "dataset-version-integration";
const DATASET_ITEM_1_ID = "11111111-1111-4111-8111-111111111111";
const DATASET_ITEM_2_ID = "22222222-2222-4222-8222-222222222222";

function createDatasetItems(): ExperimentDatasetItem[] {
return [
{
id: "item-1",
id: DATASET_ITEM_1_ID,
label: "first",
input: "hello",
expected: "world",
},
{
id: "item-2",
id: DATASET_ITEM_2_ID,
label: "second",
input: "foo",
expected: "bar",
Expand Down Expand Up @@ -55,7 +57,7 @@ describe("runExperiment integration", () => {

expect(client.appendCalls).toHaveLength(2);
const appendedIds = client.appendCalls.map((call) => call.payload.results[0]?.datasetItemId);
expect(appendedIds).toEqual(["item-1", "item-2"]);
expect(appendedIds).toEqual([DATASET_ITEM_1_ID, DATASET_ITEM_2_ID]);

expect(client.completeCalls).toHaveLength(1);
expect(client.completeCalls[0].payload.status).toBe("succeeded");
Expand Down
49 changes: 44 additions & 5 deletions packages/evals/src/voltops/run.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ const baseDataset: ExperimentDatasetInfo = {
name: "sample-dataset",
};

const BASE_DATASET_ITEM_ID = "11111111-1111-4111-8111-111111111111";

const baseConfig: ExperimentConfig = {
id: "exp-1",
runner: (() => ({ output: "ok" })) as () => ExperimentRunnerReturn<unknown>,
Expand All @@ -24,14 +26,14 @@ const baseConfig: ExperimentConfig = {

const baseItemResult: ExperimentItemResult = {
item: {
id: "item-1",
id: BASE_DATASET_ITEM_ID,
input: "question",
expected: "answer",
datasetId: baseDataset.id,
datasetVersionId: baseDataset.versionId,
datasetName: baseDataset.name,
},
itemId: "item-1",
itemId: BASE_DATASET_ITEM_ID,
index: 0,
status: "passed",
runner: {
Expand Down Expand Up @@ -80,6 +82,7 @@ describe("VoltOpsRunManager", () => {
expect(append.runId).toBe("run-1");
expect(append.payload.results).toHaveLength(1);
const result = append.payload.results[0];
expect(result.datasetItemId).toBe(BASE_DATASET_ITEM_ID);
expect(result.datasetVersionId).toBe(baseDataset.versionId);
expect(result.thresholdPassed).toBe(true);
});
Expand Down Expand Up @@ -116,7 +119,7 @@ describe("VoltOpsRunManager", () => {
expect(failCall.payload.error.message).toBe("boom");
});

it("skips run creation when dataset version is missing", async () => {
it("creates a run even when dataset version is missing", async () => {
const client = new FakeVoltOpsClient();
const manager = new VoltOpsRunManager({
client,
Expand All @@ -125,7 +128,43 @@ describe("VoltOpsRunManager", () => {
});

await manager.prepare();
expect(client.createCalls).toHaveLength(0);
expect(manager.runId).toBeUndefined();
expect(client.createCalls).toHaveLength(1);
expect(client.createCalls[0]).toEqual({
datasetVersionId: undefined,
experimentId: undefined,
triggerSource: "run-experiment",
});
expect(manager.runId).toBe("run-1");
});

it("uses null datasetItemId for non-UUID inline items", async () => {
const client = new FakeVoltOpsClient();
const manager = new VoltOpsRunManager({
client,
config: baseConfig,
dataset: { name: "inline-dataset" },
});

const inlineItemResult: ExperimentItemResult = {
...baseItemResult,
item: {
...baseItemResult.item,
id: "inline-item-1",
datasetId: undefined,
datasetVersionId: undefined,
datasetName: undefined,
},
itemId: "inline-item-1",
datasetId: undefined,
datasetVersionId: undefined,
datasetName: undefined,
};

await manager.appendResult({ item: inlineItemResult });

expect(client.createCalls).toHaveLength(1);
expect(client.appendCalls).toHaveLength(1);
expect(client.appendCalls[0]?.payload.results[0]?.datasetItemId).toBeNull();
expect(client.appendCalls[0]?.payload.results[0]?.datasetItemHash).toBe("inline-item-1");
});
});
24 changes: 17 additions & 7 deletions packages/evals/src/voltops/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,15 +192,11 @@ export class VoltOpsRunManager<ItemResult extends ExperimentItemResult = Experim

async #createRun(): Promise<void> {
const dataset = this.#dataset;
if (!dataset?.versionId) {
this.#disabled = true;
return;
}

await this.#ensureExperimentResolved();

const payload: CreateEvalRunRequest = {
datasetVersionId: dataset.versionId,
datasetVersionId: dataset?.versionId,
experimentId: this.#experimentId,
triggerSource: this.#config.voltOps?.triggerSource ?? DEFAULT_TRIGGER_SOURCE,
};
Expand Down Expand Up @@ -308,7 +304,7 @@ export class VoltOpsRunManager<ItemResult extends ExperimentItemResult = Experim
return false;
}

return Boolean(this.#dataset?.versionId);
return true;
}
}

Expand Down Expand Up @@ -400,9 +396,10 @@ function createAppendPayload(
}));

const metadata = createResultMetadata(item);
const datasetItemId = normalizeDatasetItemId(item.itemId);

return {
datasetItemId: item.itemId ?? null,
datasetItemId,
datasetItemHash: String(item.itemId ?? item.index),
datasetId: item.datasetId ?? null,
datasetVersionId: item.datasetVersionId ?? null,
Expand All @@ -419,6 +416,19 @@ function createAppendPayload(
};
}

function normalizeDatasetItemId(value: string | undefined): string | null {
if (!value) {
return null;
}

// eval_results.dataset_item_id is a UUID foreign key in the API schema.
return isUuid(value) ? value : null;
}

function isUuid(value: string): boolean {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
}

function createResultMetadata(item: ExperimentItemResult): Record<string, unknown> | null {
const metadata: Record<string, unknown> = {};

Expand Down