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
5 changes: 5 additions & 0 deletions .changeset/calm-tools-preserve-inputs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@agent-native/core": patch
---

Expose model-produced tool inputs to eval scorers so argument-level agent behavior can be verified.
29 changes: 29 additions & 0 deletions packages/core/src/eval/agent-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,15 @@ export async function createAgentRunner(

let text = "";
const toolCalls: string[] = [];
const toolCallDetails: Array<{
name: string;
id?: string;
input: unknown;
completed?: boolean;
completedSideEffect?: boolean;
isError?: boolean;
result?: string;
}> = [];
let ok = true;
let error: string | undefined;

Expand All @@ -134,7 +143,26 @@ export async function createAgentRunner(
break;
case "tool_start":
toolCalls.push(event.tool);
toolCallDetails.push({
name: event.tool,
id: event.id,
input: event.input,
});
break;
case "tool_done": {
const detail = event.id
? toolCallDetails.find((call) => call.id === event.id)
: toolCallDetails.find(
(call) => call.name === event.tool && !call.completed,
);
if (detail) {
detail.completed = true;
detail.completedSideEffect = event.completedSideEffect;
detail.isError = event.isError === true;
detail.result = event.result;
Comment thread
builder-io-integration[bot] marked this conversation as resolved.
}
break;
}
case "error":
ok = false;
error = event.error;
Expand Down Expand Up @@ -165,6 +193,7 @@ export async function createAgentRunner(
return {
text,
toolCalls,
toolCallDetails: toolCallDetails.map(({ id: _id, ...detail }) => detail),
ok,
error,
runId,
Expand Down
47 changes: 45 additions & 2 deletions packages/core/src/eval/runner.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,32 @@ describe("createAgentRunner over a mocked runAgentLoop (no real model)", () => {
const runLoop = vi.fn(
async (opts: { send: (e: AgentChatEvent) => void }) => {
opts.send({ type: "text", text: "Hello " });
opts.send({ type: "tool_start", tool: "search", input: {} });
opts.send({
type: "tool_start",
tool: "search",
id: "search-1",
input: {},
});
opts.send({
type: "tool_done",
tool: "search",
id: "search-1",
result: '{"ok":true}',
completedSideEffect: true,
});
opts.send({
type: "tool_start",
tool: "update",
id: "update-1",
input: {},
});
opts.send({
type: "tool_done",
tool: "update",
id: "update-1",
result: '{"ok":false}',
completedSideEffect: false,
});
opts.send({ type: "text", text: "world" });
return {
inputTokens: 0,
Expand All @@ -298,7 +323,25 @@ describe("createAgentRunner over a mocked runAgentLoop (no real model)", () => {

const out = await runner.runAgent({ prompt: "hi" });
expect(out.text).toBe("Hello world");
expect(out.toolCalls).toEqual(["search"]);
expect(out.toolCalls).toEqual(["search", "update"]);
expect(out.toolCallDetails).toEqual([
{
name: "search",
input: {},
completed: true,
completedSideEffect: true,
isError: false,
result: '{"ok":true}',
},
{
name: "update",
input: {},
completed: true,
completedSideEffect: false,
isError: false,
result: '{"ok":false}',
},
]);
expect(out.ok).toBe(true);

// End-to-end: a contains scorer over the real collected text.
Expand Down
9 changes: 9 additions & 0 deletions packages/core/src/eval/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,15 @@ export interface AgentRunOutput {
readonly text: string;
/** Names of tools/actions the agent invoked, in call order. */
readonly toolCalls: readonly string[];
/** Tool names, model-produced inputs, and execution outcomes in call order. */
readonly toolCallDetails?: readonly {
readonly name: string;
readonly input: unknown;
readonly completed?: boolean;
readonly completedSideEffect?: boolean;
readonly isError?: boolean;
readonly result?: string;
}[];
/** Whether the run completed without a terminal error event. */
readonly ok: boolean;
/** Terminal error message, if the run errored. */
Expand Down
76 changes: 76 additions & 0 deletions templates/content/actions/_database-property-input.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { ActionContractError } from "@agent-native/core";
import { z } from "zod";

export const databasePropertyValuesSchema = z
.record(z.string(), z.unknown())
.optional()
.describe(
"Programmatic property values keyed by exact property definition ID.",
);

export const databasePropertyEntriesSchema = z
.array(
z.object({
propertyId: z
.string()
.min(1)
.describe("Exact immutable property definition ID"),
value: z.unknown().describe("Schema-valid value for this property"),
}),
)
.max(1_000)
.optional()
.describe(
"Property values as explicit entries. Include one entry for every schema-valid writable property value the user requested, using the exact immutable property definition ID. When at least one value was requested, never pass an empty array. Do not invent or clear unmentioned properties.",
);

export function normalizeDatabasePropertyInput(input: {
propertyEntries?: Array<{ propertyId: string; value: unknown }>;
propertyValues?: Record<string, unknown>;
}): Record<string, unknown> | undefined {
if (input.propertyEntries && input.propertyValues) {
throw new ActionContractError(
"Provide propertyEntries or propertyValues, not both.",
{ errorCode: "AMBIGUOUS_PROPERTY_INPUT" },
);
}
if (!input.propertyEntries) return input.propertyValues;

const values: Record<string, unknown> = Object.create(null) as Record<
string,
unknown
>;
for (const entry of input.propertyEntries) {
if (Object.prototype.hasOwnProperty.call(values, entry.propertyId)) {
throw new ActionContractError(
`Property entry ${entry.propertyId} was provided more than once.`,
{
errorCode: "DUPLICATE_PROPERTY_INPUT",
details: { propertyId: entry.propertyId },
},
);
}
values[entry.propertyId] = entry.value;
Comment thread
builder-io-integration[bot] marked this conversation as resolved.
}
return values;
}

export function canonicalizeDatabasePropertyInput<
T extends {
propertyEntries?: Array<{ propertyId: string; value: unknown }>;
propertyValues?: Record<string, unknown>;
},
>(
input: T,
): Omit<T, "propertyEntries" | "propertyValues"> & {
propertyValues?: Record<string, unknown>;
} {
const { propertyEntries, propertyValues, ...canonicalInput } = input;
return {
...canonicalInput,
propertyValues: normalizeDatabasePropertyInput({
propertyEntries,
propertyValues,
}),
};
}
16 changes: 11 additions & 5 deletions templates/content/actions/add-database-item.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ import { buildDeepLink } from "@agent-native/core/server";
import { z } from "zod";

import type { ContentDatabaseRowMutationResult } from "../shared/api.js";
import {
canonicalizeDatabasePropertyInput,
databasePropertyEntriesSchema,
databasePropertyValuesSchema,
} from "./_database-property-input.js";
import {
createDatabaseRow,
databaseMutationEnvelopeSchema,
Expand All @@ -17,15 +22,14 @@ const schema = databaseMutationEnvelopeSchema.extend({
.max(500)
.optional()
.describe("New row page title"),
propertyValues: z
.record(z.string(), z.unknown())
.optional()
.describe("Strict property values keyed by property definition ID"),
propertyValues: databasePropertyValuesSchema,
propertyEntries: databasePropertyEntriesSchema,
});

export default defineAction({
description:
"Create one row in an exact ordinary Content database using its discovered schema revision. Strictly validates every non-Blocks property, applies the side effect once per idempotency key, and returns a verified receipt with stable row identity.",
agentInputSchema: schema.omit({ propertyValues: true }),
publicAgent: {
expose: true,
readOnly: false,
Expand All @@ -52,7 +56,9 @@ export default defineAction({
},
},
run: async (args): Promise<ContentDatabaseRowMutationResult> => {
const result = await createDatabaseRow(args);
const result = await createDatabaseRow(
canonicalizeDatabasePropertyInput(args),
);
const response = await getContentDatabaseResponse(
result.receipt.target.databaseId,
{
Expand Down
18 changes: 11 additions & 7 deletions templates/content/actions/update-database-item.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ import { buildDeepLink } from "@agent-native/core/server";
import { z } from "zod";

import type { ContentDatabaseRowMutationResult } from "../shared/api.js";
import {
canonicalizeDatabasePropertyInput,
databasePropertyEntriesSchema,
databasePropertyValuesSchema,
} from "./_database-property-input.js";
import {
databaseMutationEnvelopeSchema,
updateDatabaseRow,
Expand All @@ -16,17 +21,16 @@ const schema = databaseMutationEnvelopeSchema.extend({
.min(1)
.describe("Row revision returned by get-content-database"),
title: z.string().trim().min(1).max(500).optional(),
propertyValues: z
.record(z.string(), z.unknown())
.optional()
.describe(
"Sparse strict patch keyed by property definition ID; omitted fields are preserved and explicit null clears a value",
),
propertyValues: databasePropertyValuesSchema,
propertyEntries: databasePropertyEntriesSchema.describe(
"Sparse property patch as explicit entries; omitted fields are preserved and explicit null clears a value. Include one entry for every schema-valid writable property value the user requested, using the exact immutable property definition ID. When at least one value was requested, never pass an empty array. Do not invent or clear unmentioned properties.",
),
});

export default defineAction({
description:
"Sparsely update one exact Content database row by stable item and document IDs. Requires schema and row revisions, validates every non-Blocks property, and returns a verified idempotent receipt.",
agentInputSchema: schema.omit({ propertyValues: true }),
schema,
http: { method: "PUT" },
audit: {
Expand All @@ -44,7 +48,7 @@ export default defineAction({
: "Updated Content database row";
},
},
run: updateDatabaseRow,
run: (args) => updateDatabaseRow(canonicalizeDatabasePropertyInput(args)),
link: ({ result }) => {
const documentId = (result as ContentDatabaseRowMutationResult | null)
?.receipt.row.documentId;
Expand Down
16 changes: 11 additions & 5 deletions templates/content/actions/upsert-database-item-by-key.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ import { buildDeepLink } from "@agent-native/core/server";
import { z } from "zod";

import type { ContentDatabaseRowMutationResult } from "../shared/api.js";
import {
canonicalizeDatabasePropertyInput,
databasePropertyEntriesSchema,
databasePropertyValuesSchema,
} from "./_database-property-input.js";
import {
databaseMutationEnvelopeSchema,
upsertDatabaseRow,
Expand All @@ -18,15 +23,16 @@ const schema = databaseMutationEnvelopeSchema.extend({
"Use null to assert the key is absent and create; use the discovered row revision to update an existing key",
),
title: z.string().trim().min(1).max(500).optional(),
propertyValues: z
.record(z.string(), z.unknown())
.optional()
.describe("Sparse strict values keyed by property definition ID"),
propertyValues: databasePropertyValuesSchema,
propertyEntries: databasePropertyEntriesSchema.describe(
"Sparse property values as explicit entries. Include one entry for every schema-valid writable property value the user requested, using the exact immutable property definition ID. When at least one value was requested, never pass an empty array. Do not invent or clear unmentioned properties.",
),
});

export default defineAction({
description:
"Create or sparsely update one Content database row by that database's explicitly configured natural key. Requires schema and row compare-and-swap revisions and returns a verified idempotent receipt.",
agentInputSchema: schema.omit({ propertyValues: true }),
schema,
audit: {
recordInputs: false,
Expand All @@ -43,7 +49,7 @@ export default defineAction({
: "Upserted Content database row by natural key";
},
},
run: upsertDatabaseRow,
run: (args) => upsertDatabaseRow(canonicalizeDatabasePropertyInput(args)),
link: ({ result }) => {
const documentId = (result as ContentDatabaseRowMutationResult | null)
?.receipt.row.documentId;
Expand Down
1 change: 1 addition & 0 deletions templates/content/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"test:parity": "vitest --run parity",
"test:parity-capabilities": "vitest --run parity actions/content-database-lifecycle.db.test.ts actions/bind-content-database-source-field.db.test.ts actions/_local-file-documents.test.ts actions/builder-source-review-gates.db.test.ts",
"eval:parity": "agent-native eval parity",
"eval:property-preservation": "tsx parity/run-database-create-property-preservation.ts",
"format.fix": "oxfmt --write .",
"typecheck": "agent-native typecheck",
"migrate:production": "tsx scripts/migrate-production.ts",
Expand Down
13 changes: 11 additions & 2 deletions templates/content/parity/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ PR 2.2 adds two executable tiers:
existing action tests, make no model calls, and require no private provider
credentials.
- Gated agent evals run through `agent-native eval parity`. They are opt-in via
`CONTENT_PARITY_EVALS=1`, capped to four initial scenarios, and should be
`CONTENT_PARITY_EVALS=1`, kept to a small explicit scenario set, and should be
reserved for manual or nightly checks.

## Deterministic Checks
Expand All @@ -33,14 +33,23 @@ cd templates/content
CONTENT_PARITY_EVALS=1 ANTHROPIC_API_KEY=... ./node_modules/.bin/agent-native eval parity
```

The database-create property-preservation regression has a dedicated
fixture-only runner so it can inspect model-produced arguments without loading
or executing unrelated Content actions:

```bash
CONTENT_PARITY_EVALS=1 pnpm eval:property-preservation
```

With `CONTENT_PARITY_EVALS` unset, parity evals return skipped rows and do not
call the agent runner. The CLI still exits `0`, but both readable and JSON
reports mark each row with `status: "skipped"` and a `skipReason` such as
`Skipped because CONTENT_PARITY_EVALS is unset`.

With the gate set, the eval files run the four PR 2.2 scenarios:
With the gate set, the eval files include these scenarios:

- `database-source-scope`
- `database-create-property-preservation`
- `document-search-edit`
- `local-file-source-truth`
- `builder-source-review-readonly`
Expand Down
Loading
Loading