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
9 changes: 7 additions & 2 deletions packages/ai/src/task/AgentTask.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,12 @@ import { collectToolUseIds, uniquifyToolCallIds } from "./ToolCallIds";
import type { ToolCallingTaskInput, ToolCallingTaskOutput } from "./ToolCallingTask";
import { ToolCallingInputSchema, ToolCallingTask } from "./ToolCallingTask";
import type { ToolCall, ToolDefinition } from "./ToolCallingUtils";
import { compileToolValidators, sanitizeToolArgs, ToolCallError } from "./ToolCallingUtils";
import {
compileToolValidators,
describeSchemaErrors,
sanitizeToolArgs,
ToolCallError,
} from "./ToolCallingUtils";
import { RetryableJobError } from "@workglow/job-queue";

/** Rounds before the loop gives up on the model reaching an answer. */
Expand Down Expand Up @@ -906,7 +911,7 @@ export class AgentTask extends Task<AgentTaskInput, AgentTaskOutput, AgentTaskCo
if (validator) {
const check = validator.validate(sanitized);
if (!check.valid) {
const detail = check.errors.map((error) => error.message).join("; ") || "invalid arguments";
const detail = describeSchemaErrors(check.errors);
// The answer itself failed its schema: say so as a correction to make,
// which is what gets a model to resubmit rather than give up.
const text =
Expand Down
71 changes: 71 additions & 0 deletions packages/ai/src/task/ToolCallingUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,77 @@ export function compileToolValidators(
return validators;
}

/** One schema error as a validator reports it: the parts this module reads. */
interface SchemaErrorLike {
readonly code?: string;
readonly message: string;
readonly data?: unknown;
}

/** The JSON type a value has, in the spelling a schema's `type` uses. */
function jsonTypeOf(value: unknown): string {
if (value === null) return "null";
if (Array.isArray(value)) return "array";
if (typeof value === "number") return Number.isInteger(value) ? "integer" : "number";
return typeof value;
}

function branchAccepts(branch: unknown, type: string): boolean {
if (branch === null || typeof branch !== "object") return false;
const declared = (branch as { type?: unknown }).type;
if (declared === undefined) return false;
const types = Array.isArray(declared) ? declared : [declared];
return types.includes(type) || (type === "integer" && types.includes("number"));
}

/**
* The validation errors as one line a model can act on.
*
* An `anyOf` that fails reports only that no branch matched, with the whole
* value and every branch echoed back. For the commonest shape — an object or
* null — that hides the one thing to fix: the object branch's own complaint,
* such as a property its schema does not allow. Where exactly one branch takes
* the value's JSON type, its errors are reported instead, at the path of the
* value that failed.
*/
export function describeSchemaErrors(errors: ReadonlyArray<{ readonly message: string }>): string {
const lines = errors.flatMap((error) => explainSchemaError(error as SchemaErrorLike, 0));
return lines.join("; ") || "invalid arguments";
}

function explainSchemaError(error: SchemaErrorLike, depth: number): string[] {
const data = error.data as
| { readonly pointer?: unknown; readonly value?: unknown; readonly anyOf?: unknown }
| undefined;
if (
depth > 4 ||
(error.code !== "any-of-error" && error.code !== "one-of-error") ||
data === undefined ||
!Array.isArray(data.anyOf ?? (data as { oneOf?: unknown }).oneOf)
) {
return [error.message];
}
const branches = (data.anyOf ?? (data as { oneOf?: unknown }).oneOf) as unknown[];
const candidates = branches.filter((branch) => branchAccepts(branch, jsonTypeOf(data.value)));
if (candidates.length !== 1) return [error.message];
let inner: SchemaErrorLike[];
try {
const result = compileSchema(candidates[0] as JsonSchema).validate(data.value);
if (result.valid) return [error.message];
inner = result.errors as SchemaErrorLike[];
} catch {
return [error.message];
}
// The branch was validated on its own, so its paths start at `#`: re-root them
// at the value's place in the whole answer.
const at = typeof data.pointer === "string" ? data.pointer : "#";
return inner.flatMap((e) =>
explainSchemaError(e, depth + 1).map((line) =>
at === "#" ? line : line.replace(/`#(?=[/`])/g, `\`${at}`)
)
);
}

/**
* Filter tool calls whose `input` doesn't match the tool's compiled
* `inputSchema`. Tools without a compiled validator (compile failed,
Expand Down
47 changes: 47 additions & 0 deletions packages/test/src/test/ai/describeSchemaErrors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/**
* @license
* Copyright 2026 Steven Roussey <sroussey@gmail.com>
* SPDX-License-Identifier: Apache-2.0
*/

import { describeSchemaErrors } from "@workglow/ai";
import { compileSchema } from "@workglow/util/schema";
import { describe, expect, it } from "vitest";

const answer = compileSchema({
type: "object",
properties: {
offering_terms: {
anyOf: [
{
type: "object",
properties: { price: { type: "number" } },
required: ["price"],
additionalProperties: false,
},
{ type: "null" },
],
},
},
required: ["offering_terms"],
});

describe("describeSchemaErrors", () => {
it("reports a nullable object's own errors, at their path in the answer", () => {
const { errors } = answer.validate({ offering_terms: { price: 10, trust_total: 5 } });
const detail = describeSchemaErrors(errors);
expect(detail).toContain("trust_total");
expect(detail).toContain("#/offering_terms");
expect(detail).not.toContain("does not match any schema");
});

it("keeps the any-of message when no single branch takes the value's type", () => {
const { errors } = answer.validate({ offering_terms: "ten" });
expect(describeSchemaErrors(errors)).toContain("does not match any schema");
});

it("passes other errors through", () => {
const { errors } = answer.validate({});
expect(describeSchemaErrors(errors)).toContain("offering_terms");
});
});
Loading