Skip to content
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
210 changes: 210 additions & 0 deletions templates/content/actions/_database-property-input.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
import { ActionContractError } from "@agent-native/core";
import { z } from "zod";

const nullable = <T extends z.ZodTypeAny>(schema: T) =>
z.union([schema, z.null()]);

const propertyIdSchema = z
.string()
.min(1)
.describe("Exact immutable property definition ID");

const stringPropertyEntry = (
propertyType: "text" | "place" | "phone" | "url" | "email",
valueDescription: string,
) =>
z
.object({
propertyId: propertyIdSchema,
propertyType: z.literal(propertyType),
value: nullable(z.string()).describe(valueDescription),
})
.strict();

const optionPropertyEntry = (propertyType: "select" | "status") =>
z
.object({
propertyId: propertyIdSchema,
propertyType: z.literal(propertyType),
value: nullable(z.string()).describe(
"Exact option ID or exact option label from the discovered property contract; null explicitly clears the value",
),
})
.strict();

export const databasePropertyEntrySchema = z.discriminatedUnion(
"propertyType",
[
stringPropertyEntry("text", "Text value; null explicitly clears the value"),
stringPropertyEntry(
"place",
"Place text; null explicitly clears the value",
),
stringPropertyEntry(
"phone",
"Phone text; null explicitly clears the value",
),
stringPropertyEntry(
"url",
"Absolute http/https URL; null explicitly clears the value",
),
stringPropertyEntry(
"email",
"Email address; null explicitly clears the value",
),
z
.object({
propertyId: propertyIdSchema,
propertyType: z.literal("number"),
value: nullable(z.number().finite()).describe(
"Finite number; use a JSON number rather than numeric text, or null to explicitly clear",
),
})
.strict(),
z
.object({
propertyId: propertyIdSchema,
propertyType: z.literal("checkbox"),
value: nullable(z.boolean()).describe(
"Boolean; use true or false rather than text, or null to explicitly clear",
),
})
.strict(),
optionPropertyEntry("select"),
optionPropertyEntry("status"),
z
.object({
propertyId: propertyIdSchema,
propertyType: z.literal("multi_select"),
value: nullable(z.array(z.string())).describe(
"Option IDs or exact option labels from the discovered property contract; null explicitly clears the value",
),
})
.strict(),
z
.object({
propertyId: propertyIdSchema,
propertyType: z.literal("date"),
value: nullable(
z.union([
z.string(),
z
.object({
start: z.string(),
end: z.string().optional(),
includeTime: z.boolean().optional(),
})
.strict(),
]),
).describe(
"ISO date/date-time string or { start, end?, includeTime? }; null explicitly clears the value",
),
})
.strict(),
z
.object({
propertyId: propertyIdSchema,
propertyType: z.literal("person"),
value: nullable(z.array(z.string())).describe(
"Person identifiers from the discovered property contract; null explicitly clears the value",
),
})
.strict(),
z
.object({
propertyId: propertyIdSchema,
propertyType: z.literal("files_media"),
value: nullable(z.array(z.string())).describe(
"Absolute http/https file URLs; null explicitly clears the value",
),
})
.strict(),
],
);

export type DatabasePropertyEntry = z.infer<typeof databasePropertyEntrySchema>;

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(databasePropertyEntrySchema)
.max(1_000)
Comment on lines +134 to +136

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.

🟡 Reject empty typed property entry lists

propertyEntries is optional, but when supplied as [] it is accepted and canonicalized to an empty propertyValues object plus empty type assertions. This lets an agent perform a successful no-op create/update/upsert despite the contract saying an empty list must never be used, preserving the original dropped-property failure mode. Require .min(1) when the field is present or canonicalize an empty list to the omitted representation.

Fix in Builder

.optional()
.describe(
"Typed property values as explicit entries. Copy each propertyType from the discovered mutation contract and include one entry for every 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?: DatabasePropertyEntry[];
propertyValues?: Record<string, unknown>;
}): {
propertyValues: Record<string, unknown> | undefined;
propertyTypeAssertions: Record<string, string> | undefined;
} {
if (input.propertyEntries && input.propertyValues) {
throw new ActionContractError(
"Provide propertyEntries or propertyValues, not both.",
{ errorCode: "AMBIGUOUS_PROPERTY_INPUT" },
);
}
if (!input.propertyEntries) {
return {
propertyValues: input.propertyValues,
propertyTypeAssertions: undefined,
};
}

const values: Record<string, unknown> = Object.create(null) as Record<
string,
unknown
>;
const propertyTypes: Record<string, string> = Object.create(null) as Record<
string,
string
>;
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.
propertyTypes[entry.propertyId] = entry.propertyType;
}
return {
propertyValues: values,
propertyTypeAssertions: propertyTypes,
};
}

export function canonicalizeDatabasePropertyInput<
T extends {
propertyEntries?: DatabasePropertyEntry[];
propertyValues?: Record<string, unknown>;
},
>(
input: T,
): Omit<T, "propertyEntries" | "propertyValues"> & {
propertyValues?: Record<string, unknown>;
propertyTypeAssertions?: Record<string, string>;
} {
const { propertyEntries, propertyValues, ...canonicalInput } = input;
const normalized = normalizeDatabasePropertyInput({
propertyEntries,
propertyValues,
});
return {
...canonicalInput,
propertyValues: normalized.propertyValues,
propertyTypeAssertions: normalized.propertyTypeAssertions,
};
}
Loading
Loading