diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 55307cbd4..fff770f1b 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## [Unreleased] + ### Breaking Changes - Removed TypeBox root exports (`Type`, `Static`, and `TSchema`) from the package entrypoint, so callers importing those symbols from `@oh-my-pi/pi-ai` must migrate to `zod` or `@oh-my-pi/pi-ai/types` @@ -19,11 +20,17 @@ ### Changed +- Changed Azure OpenAI Responses tool schema conversion to sanitize tool parameter schemas and rewrite `oneOf` branches as `anyOf` so tool calls remain compatible with Azure's schema expectations +- Changed `Static` to extract a schema object’s `static` type when present, improving inferred tool argument types for non-Zod parameter definitions - Changed `Static` typing behavior so it now infers argument types from Zod schemas and defaults to `unknown` for non-Zod JSON Schema parameter definitions - Restored the default steady-state stream idle timeout to 120s (regressed in 15.0.0). 30s was too aggressive for reasoning models, slow proxies, and tool-call planning gaps, surfacing as repeated `Provider stream stalled while waiting for the next event` errors. Existing `PI_STREAM_IDLE_TIMEOUT_MS` / `PI_OPENAI_STREAM_IDLE_TIMEOUT_MS` overrides are unchanged. ### Fixed +- Preserved top-level unknown fields in validated tool-call arguments so extra root properties are retained after schema coercion +- Fixed coercion for Zod `record` fields by parsing JSON-stringified record arguments into objects +- Validated legacy draft-07 JSON Schema tool parameters directly instead of converting through Zod, improving support for features like `$ref`, `definitions`, `nullable`, and `uniqueItems` +- Fixed Cloud Code Assist schema preparation to strip unsupported `propertyNames` and fall back to a minimal tool schema when schema meta-validation detects malformed keywords - Fixed OpenAI Completions streaming to avoid treating non-output chunks (including role-only preambles) as progress events so idle-timeout watchdog behavior no longer hangs on no-op streamed chunks - Fixed Cloud Code Assist schema compatibility checks by replacing strict AJV meta-schema validation with structural JSON Schema validation to avoid rejecting structurally valid tool schemas - Fixed lazy built-in provider streams (`anthropic-messages`, `bedrock-converse-stream`, `cursor-agent`, `google-*`, `ollama-chat`, `openai-*`) prematurely aborting slow first-token responses with `Provider stream stalled while waiting for the next event`. The lazy-stream watchdog wrapper was treating the synthetic `start` event (yielded immediately by every provider before the model emits any tokens) as the first real item, which caused the watchdog to drop from `firstItemTimeoutMs` (100s) to `idleTimeoutMs` (30s) before the upstream model had produced anything. The shared `iterateWithIdleTimeout` now keeps `awaitingFirstItem` true until a real progress item arrives, and the lazy-stream wrapper marks `start` as a non-progress keepalive ([#1073](https://github.com/can1357/oh-my-pi/pull/1073) regression). diff --git a/packages/ai/src/providers/azure-openai-responses.ts b/packages/ai/src/providers/azure-openai-responses.ts index 443e69fed..6e2d63afa 100644 --- a/packages/ai/src/providers/azure-openai-responses.ts +++ b/packages/ai/src/providers/azure-openai-responses.ts @@ -26,7 +26,7 @@ import { getStreamFirstEventTimeoutMs, iterateWithIdleTimeout, } from "../utils/idle-iterator"; -import { toolWireSchema } from "../utils/schema/wire"; +import { sanitizeSchemaForOpenAIResponses, toolWireSchema } from "../utils/schema"; import { wrapFetchForSseDebug } from "../utils/sse-debug"; import { mapToOpenAIResponsesToolChoice } from "../utils/tool-choice"; import { normalizeOpenAIResponsesPromptCacheKey, supportsDeveloperRole } from "./openai-responses"; @@ -331,7 +331,7 @@ function convertTools(tools: Tool[]): OpenAITool[] { type: "function", name: tool.name, description: tool.description || "", - parameters: toolWireSchema(tool), + parameters: sanitizeSchemaForOpenAIResponses(toolWireSchema(tool)), strict: false, })); } diff --git a/packages/ai/src/providers/openai-responses.ts b/packages/ai/src/providers/openai-responses.ts index 241040092..0e7cbb64a 100644 --- a/packages/ai/src/providers/openai-responses.ts +++ b/packages/ai/src/providers/openai-responses.ts @@ -6,20 +6,20 @@ import type { ResponseInput, } from "openai/resources/responses/responses"; import { getEnvApiKey } from "../stream"; -import { - type AssistantMessage, - type CacheRetention, - type Context, - type FetchImpl, - type MessageAttribution, - type Model, - type OpenAICompat, - type ProviderSessionState, - type ServiceTier, - type StreamFunction, - type StreamOptions, - type Tool, - type ToolChoice, +import type { + AssistantMessage, + CacheRetention, + Context, + FetchImpl, + MessageAttribution, + Model, + OpenAICompat, + ProviderSessionState, + ServiceTier, + StreamFunction, + StreamOptions, + Tool, + ToolChoice, } from "../types"; import { createOpenAIResponsesHistoryPayload, diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 6c6a5f761..e94f0493e 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -552,7 +552,7 @@ export type TJsonSchema = Record; export type TSchema = ZodType | TJsonSchema; /** Resolve parameter types for tool execution / handlers. */ -export type Static = S extends ZodType ? z.infer : unknown; +export type Static = S extends ZodType ? z.infer : S extends { static: infer T } ? T : unknown; export interface Tool { name: string; diff --git a/packages/ai/src/utils/schema/fields.ts b/packages/ai/src/utils/schema/fields.ts index fe1f9d2ae..82a2a422e 100644 --- a/packages/ai/src/utils/schema/fields.ts +++ b/packages/ai/src/utils/schema/fields.ts @@ -21,6 +21,7 @@ export const UNSUPPORTED_SCHEMA_FIELDS = new Set([ "unevaluatedItems", "patternProperties", "additionalProperties", + "propertyNames", "minItems", "maxItems", "minLength", @@ -142,6 +143,13 @@ export const COMBINATOR_KEYS = ["anyOf", "allOf", "oneOf"] as const; * Cloud Code Assist Claude unsupported schema fields. * Much smaller than UNSUPPORTED_SCHEMA_FIELDS (Google) because CCA supports * validation keywords like additionalProperties, minLength, pattern, etc. - * Only meta/reference keywords that CCA cannot resolve are stripped. + * Meta/reference keywords plus object-key validators that CCA cannot resolve are stripped. */ -export const CCA_UNSUPPORTED_SCHEMA_FIELDS = new Set(["$schema", "$ref", "$defs", "$dynamicRef", "$dynamicAnchor"]); +export const CCA_UNSUPPORTED_SCHEMA_FIELDS = new Set([ + "$schema", + "$ref", + "$defs", + "$dynamicRef", + "$dynamicAnchor", + "propertyNames", +]); diff --git a/packages/ai/src/utils/schema/from-typebox.ts b/packages/ai/src/utils/schema/from-typebox.ts deleted file mode 100644 index 45589359e..000000000 --- a/packages/ai/src/utils/schema/from-typebox.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Convert a plain JSON Schema object (OpenAPI / draft-07 style) into a Zod schema. - * - * Internal validation runs against Zod; parameters may be authored as Zod directly - * or supplied as JSON Schema (legacy extension payloads). This helper turns JSON - * Schema into Zod once at validation boundary and caches by object identity. - * - * Delegates to `z.fromJSONSchema` — no dedicated TypeBox dependency at runtime. - */ - -import { type ZodType, z } from "zod/v4"; - -/** WeakMap cache so repeated registrations of the same JSON Schema object reuse the Zod result. */ -const cache = new WeakMap(); - -/** - * Convert JSON Schema (plain object) to a Zod schema. Returns a cached - * value when called repeatedly with the same source object. - */ -export function fromTypeBox(schema: Record | unknown): ZodType { - if (typeof schema === "object" && schema !== null) { - const cached = cache.get(schema as object); - if (cached) return cached; - // Pass the schema through Zod's JSON Schema importer. We pass through - // any non-standard keys; Zod silently ignores unknown keywords. - const zodSchema = z.fromJSONSchema(schema as Parameters[0]) as ZodType; - cache.set(schema as object, zodSchema); - return zodSchema; - } - // Defensive fallback — the validator only ever calls this with object - // schemas, but if someone hands us a scalar we return an `unknown` Zod - // schema so callers don't crash mid-pipeline. - return z.unknown() as unknown as ZodType; -} diff --git a/packages/ai/src/utils/schema/index.ts b/packages/ai/src/utils/schema/index.ts index ba31d6fa5..43b91243c 100644 --- a/packages/ai/src/utils/schema/index.ts +++ b/packages/ai/src/utils/schema/index.ts @@ -3,7 +3,7 @@ export * from "./compatibility"; export * from "./dereference"; export * from "./equality"; export * from "./fields"; -export * from "./from-typebox"; +export * from "./json-schema-validator"; export * from "./meta-validator"; export * from "./normalize-cca"; export * from "./sanitize-google"; diff --git a/packages/ai/src/utils/schema/json-schema-validator.ts b/packages/ai/src/utils/schema/json-schema-validator.ts new file mode 100644 index 000000000..3a75253f3 --- /dev/null +++ b/packages/ai/src/utils/schema/json-schema-validator.ts @@ -0,0 +1,395 @@ +import { areJsonValuesEqual } from "./equality"; + +export interface JsonSchemaValidationIssue { + path: PropertyKey[]; + message: string; + expectedTypes?: string[]; + keyword?: string; +} + +export interface JsonSchemaValidationResult { + success: boolean; + issues: JsonSchemaValidationIssue[]; +} + +interface ValidationContext { + root: unknown; + seenRefs: Set; +} + +function isJsonObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function pushIssue( + issues: JsonSchemaValidationIssue[], + path: readonly PropertyKey[], + message: string, + options: { expectedTypes?: string[]; keyword?: string } = {}, +): void { + issues.push({ path: [...path], message, ...options }); +} + +function typeOfJsonValue(value: unknown): string { + if (value === null) return "null"; + if (Array.isArray(value)) return "array"; + if (typeof value === "number" && Number.isInteger(value)) return "integer"; + return typeof value; +} + +function matchesJsonSchemaType(value: unknown, type: string): boolean { + switch (type) { + case "string": + return typeof value === "string"; + case "number": + return typeof value === "number" && Number.isFinite(value); + case "integer": + return typeof value === "number" && Number.isInteger(value); + case "boolean": + return typeof value === "boolean"; + case "object": + return isJsonObject(value); + case "array": + return Array.isArray(value); + case "null": + return value === null; + default: + return false; + } +} + +function schemaTypes(schema: Record): string[] { + const raw = schema.type; + const types = + typeof raw === "string" + ? [raw] + : Array.isArray(raw) + ? raw.filter((entry): entry is string => typeof entry === "string") + : []; + if (schema.nullable === true && !types.includes("null")) { + return [...types, "null"]; + } + return types; +} + +function decodePointerToken(token: string): string { + return token.replace(/~1/g, "/").replace(/~0/g, "~"); +} + +function resolveLocalRef(root: unknown, ref: string): unknown | undefined { + if (ref === "#") return root; + if (!ref.startsWith("#/")) return undefined; + let current: unknown = root; + for (const rawToken of ref.slice(2).split("/")) { + const token = decodePointerToken(rawToken); + if (!isJsonObject(current) && !Array.isArray(current)) return undefined; + current = (current as Record)[token]; + } + return current; +} + +function isRequiredSet(value: unknown): value is string[] { + return Array.isArray(value) && value.every(entry => typeof entry === "string"); +} + +function validateSchemaNode( + schema: unknown, + value: unknown, + path: readonly PropertyKey[], + ctx: ValidationContext, + issues: JsonSchemaValidationIssue[], +): boolean { + if (schema === true) return true; + if (schema === false) { + pushIssue(issues, path, "must not match false schema", { keyword: "false" }); + return false; + } + if (!isJsonObject(schema)) { + pushIssue(issues, path, "schema must be an object or boolean", { keyword: "schema" }); + return false; + } + + const ref = schema.$ref; + if (typeof ref === "string") { + if (ctx.seenRefs.has(ref)) return true; + const resolved = resolveLocalRef(ctx.root, ref); + if (resolved === undefined) { + pushIssue(issues, path, `unresolved reference ${ref}`, { keyword: "$ref" }); + return false; + } + ctx.seenRefs.add(ref); + const ok = validateSchemaNode(resolved, value, path, ctx, issues); + ctx.seenRefs.delete(ref); + return ok; + } + + if (value === null && schema.nullable === true) return true; + + let valid = true; + const types = schemaTypes(schema); + if (types.length > 0 && !types.some(type => matchesJsonSchemaType(value, type))) { + pushIssue(issues, path, `expected ${types.join(" or ")}, received ${typeOfJsonValue(value)}`, { + keyword: "type", + expectedTypes: types, + }); + return false; + } + + if ("const" in schema && !areJsonValuesEqual(value, schema.const)) { + pushIssue(issues, path, "must equal const value", { keyword: "const" }); + valid = false; + } + + if (Array.isArray(schema.enum) && !schema.enum.some(entry => areJsonValuesEqual(entry, value))) { + pushIssue(issues, path, "must be one of the allowed enum values", { keyword: "enum" }); + valid = false; + } + + for (const keyword of ["anyOf", "oneOf", "allOf"] as const) { + const branches = schema[keyword]; + if (!Array.isArray(branches)) continue; + if (keyword === "allOf") { + for (const branch of branches) { + valid = validateSchemaNode(branch, value, path, ctx, issues) && valid; + } + continue; + } + + let matches = 0; + let firstIssues: JsonSchemaValidationIssue[] | undefined; + for (const branch of branches) { + const branchIssues: JsonSchemaValidationIssue[] = []; + if (validateSchemaNode(branch, value, path, ctx, branchIssues)) { + matches += 1; + } else if (!firstIssues) { + firstIssues = branchIssues; + } + } + const branchValid = keyword === "anyOf" ? matches > 0 : matches === 1; + if (!branchValid) { + if (matches === 0 && firstIssues && firstIssues.length > 0) { + issues.push(...firstIssues); + } else { + pushIssue( + issues, + path, + keyword === "anyOf" ? "must match at least one schema" : "must match exactly one schema", + { + keyword, + }, + ); + } + valid = false; + } + } + + if ("not" in schema) { + const notIssues: JsonSchemaValidationIssue[] = []; + if (validateSchemaNode(schema.not, value, path, ctx, notIssues)) { + pushIssue(issues, path, "must not match excluded schema", { keyword: "not" }); + valid = false; + } + } + + if (isJsonObject(value)) { + valid = validateObjectKeywords(schema, value, path, ctx, issues) && valid; + } + if (Array.isArray(value)) { + valid = validateArrayKeywords(schema, value, path, ctx, issues) && valid; + } + if (typeof value === "string") { + valid = validateStringKeywords(schema, value, path, issues) && valid; + } + if (typeof value === "number" && Number.isFinite(value)) { + valid = validateNumberKeywords(schema, value, path, issues) && valid; + } + + return valid; +} + +function validateObjectKeywords( + schema: Record, + value: Record, + path: readonly PropertyKey[], + ctx: ValidationContext, + issues: JsonSchemaValidationIssue[], +): boolean { + let valid = true; + const properties = isJsonObject(schema.properties) ? schema.properties : {}; + if (isRequiredSet(schema.required)) { + for (const key of schema.required) { + if (!(key in value)) { + pushIssue(issues, [...path, key], "is required", { keyword: "required" }); + valid = false; + } + } + } + + for (const [key, propertySchema] of Object.entries(properties)) { + if (!(key in value)) continue; + valid = validateSchemaNode(propertySchema, value[key], [...path, key], ctx, issues) && valid; + } + + if (schema.propertyNames !== undefined) { + for (const key of Object.keys(value)) { + valid = validateSchemaNode(schema.propertyNames, key, [...path, key], ctx, issues) && valid; + } + } + + const known = new Set(Object.keys(properties)); + const additional = schema.additionalProperties; + if (additional === false) { + for (const key of Object.keys(value)) { + if (known.has(key)) continue; + pushIssue(issues, [...path, key], "must not be present", { keyword: "additionalProperties" }); + valid = false; + } + } else if (additional !== undefined && additional !== true) { + for (const [key, entry] of Object.entries(value)) { + if (known.has(key)) continue; + valid = validateSchemaNode(additional, entry, [...path, key], ctx, issues) && valid; + } + } + + if (typeof schema.minProperties === "number" && Object.keys(value).length < schema.minProperties) { + pushIssue(issues, path, `must have at least ${schema.minProperties} properties`, { keyword: "minProperties" }); + valid = false; + } + if (typeof schema.maxProperties === "number" && Object.keys(value).length > schema.maxProperties) { + pushIssue(issues, path, `must have at most ${schema.maxProperties} properties`, { keyword: "maxProperties" }); + valid = false; + } + + return valid; +} + +function validateArrayKeywords( + schema: Record, + value: unknown[], + path: readonly PropertyKey[], + ctx: ValidationContext, + issues: JsonSchemaValidationIssue[], +): boolean { + let valid = true; + if (typeof schema.minItems === "number" && value.length < schema.minItems) { + pushIssue(issues, path, `must have at least ${schema.minItems} items`, { keyword: "minItems" }); + valid = false; + } + if (typeof schema.maxItems === "number" && value.length > schema.maxItems) { + pushIssue(issues, path, `must have at most ${schema.maxItems} items`, { keyword: "maxItems" }); + valid = false; + } + if (schema.uniqueItems === true) { + for (let i = 0; i < value.length; i += 1) { + for (let j = i + 1; j < value.length; j += 1) { + if (!areJsonValuesEqual(value[i], value[j])) continue; + pushIssue(issues, [...path, j], "must be unique", { keyword: "uniqueItems" }); + valid = false; + } + } + } + + const items = schema.items; + if (Array.isArray(items)) { + const limit = Math.min(items.length, value.length); + for (let i = 0; i < limit; i += 1) { + valid = validateSchemaNode(items[i], value[i], [...path, i], ctx, issues) && valid; + } + if (schema.additionalItems === false && value.length > items.length) { + for (let i = items.length; i < value.length; i += 1) { + pushIssue(issues, [...path, i], "must not be present", { keyword: "additionalItems" }); + valid = false; + } + } else if (schema.additionalItems !== undefined && schema.additionalItems !== true) { + for (let i = items.length; i < value.length; i += 1) { + valid = validateSchemaNode(schema.additionalItems, value[i], [...path, i], ctx, issues) && valid; + } + } + } else if (items !== undefined) { + for (let i = 0; i < value.length; i += 1) { + valid = validateSchemaNode(items, value[i], [...path, i], ctx, issues) && valid; + } + } + + return valid; +} + +function validateStringKeywords( + schema: Record, + value: string, + path: readonly PropertyKey[], + issues: JsonSchemaValidationIssue[], +): boolean { + let valid = true; + if (typeof schema.minLength === "number" && value.length < schema.minLength) { + pushIssue(issues, path, `must be at least ${schema.minLength} characters`, { keyword: "minLength" }); + valid = false; + } + if (typeof schema.maxLength === "number" && value.length > schema.maxLength) { + pushIssue(issues, path, `must be at most ${schema.maxLength} characters`, { keyword: "maxLength" }); + valid = false; + } + if (typeof schema.pattern === "string") { + try { + if (!new RegExp(schema.pattern).test(value)) { + pushIssue(issues, path, "must match pattern", { keyword: "pattern" }); + valid = false; + } + } catch { + pushIssue(issues, path, "schema pattern is invalid", { keyword: "pattern" }); + valid = false; + } + } + return valid; +} + +function validateNumberKeywords( + schema: Record, + value: number, + path: readonly PropertyKey[], + issues: JsonSchemaValidationIssue[], +): boolean { + let valid = true; + if (typeof schema.minimum === "number" && value < schema.minimum) { + pushIssue(issues, path, `must be >= ${schema.minimum}`, { keyword: "minimum" }); + valid = false; + } + if (typeof schema.maximum === "number" && value > schema.maximum) { + pushIssue(issues, path, `must be <= ${schema.maximum}`, { keyword: "maximum" }); + valid = false; + } + if (typeof schema.exclusiveMinimum === "number" && value <= schema.exclusiveMinimum) { + pushIssue(issues, path, `must be > ${schema.exclusiveMinimum}`, { keyword: "exclusiveMinimum" }); + valid = false; + } + if (typeof schema.exclusiveMaximum === "number" && value >= schema.exclusiveMaximum) { + pushIssue(issues, path, `must be < ${schema.exclusiveMaximum}`, { keyword: "exclusiveMaximum" }); + valid = false; + } + if (schema.exclusiveMinimum === true && typeof schema.minimum === "number" && value <= schema.minimum) { + pushIssue(issues, path, `must be > ${schema.minimum}`, { keyword: "exclusiveMinimum" }); + valid = false; + } + if (schema.exclusiveMaximum === true && typeof schema.maximum === "number" && value >= schema.maximum) { + pushIssue(issues, path, `must be < ${schema.maximum}`, { keyword: "exclusiveMaximum" }); + valid = false; + } + if (typeof schema.multipleOf === "number" && schema.multipleOf > 0) { + const quotient = value / schema.multipleOf; + if (Math.abs(quotient - Math.round(quotient)) > Number.EPSILON * 10) { + pushIssue(issues, path, `must be a multiple of ${schema.multipleOf}`, { keyword: "multipleOf" }); + valid = false; + } + } + return valid; +} + +export function validateJsonSchemaValue(schema: unknown, value: unknown): JsonSchemaValidationResult { + const issues: JsonSchemaValidationIssue[] = []; + const success = validateSchemaNode(schema, value, [], { root: schema, seenRefs: new Set() }, issues); + return { success, issues }; +} + +export function isJsonSchemaValueValid(schema: unknown, value: unknown): boolean { + return validateJsonSchemaValue(schema, value).success; +} diff --git a/packages/ai/src/utils/schema/meta-validator.ts b/packages/ai/src/utils/schema/meta-validator.ts index 2e8376d48..578a4c6a6 100644 --- a/packages/ai/src/utils/schema/meta-validator.ts +++ b/packages/ai/src/utils/schema/meta-validator.ts @@ -1,19 +1,12 @@ +import { areJsonValuesEqual } from "./equality"; + /** * Hand-rolled JSON Schema meta-validator. * - * Replaces a singleton `Ajv2020.validateSchema` call with a tiny structural - * pass that covers every JSON Schema keyword the rest of the codebase - * actually emits. The full meta-schema is not necessary because: - * - * 1. Tool schemas are authored either with Zod (validated by Zod itself) or - * TypeBox (which already constructs structurally-correct JSON Schema). - * 2. The transform pipeline in `normalize-cca.ts` / `compatibility.ts` - * mutates schemas in narrow, known ways. The meta-check just guards - * against gross structural breakage introduced by those transforms. - * - * Returns `true` if the schema is well-formed enough for downstream - * consumers, otherwise `false`. Unknown keywords are accepted (forward - * compatibility); known keywords are checked for their expected shape. + * Replaces the old AJV meta-schema check in request hot paths with a small + * structural validator for the JSON Schema subset this repo emits and forwards. + * Unknown keywords are accepted for forward compatibility; known keywords are + * checked so malformed provider payloads still fall back instead of being sent. */ type Json = unknown; @@ -24,10 +17,40 @@ function isPlainObject(value: Json): value is Record { const TYPE_NAMES = new Set(["string", "number", "integer", "boolean", "object", "array", "null"]); -/** - * Validate a single sub-schema node. Recurses into combinators, property - * maps, and items lists. Anything unrecognized is left untouched. - */ +function isNonNegativeInteger(value: Json): value is number { + return typeof value === "number" && Number.isInteger(value) && value >= 0; +} + +function hasUniqueJsonValues(values: readonly unknown[]): boolean { + for (let i = 0; i < values.length; i += 1) { + for (let j = i + 1; j < values.length; j += 1) { + if (areJsonValuesEqual(values[i], values[j])) return false; + } + } + return true; +} + +function checkTypeKeyword(value: Json): boolean { + if (typeof value === "string") return TYPE_NAMES.has(value); + if (!Array.isArray(value) || value.length === 0) return false; + const seen = new Set(); + for (const entry of value) { + if (typeof entry !== "string" || !TYPE_NAMES.has(entry) || seen.has(entry)) return false; + seen.add(entry); + } + return true; +} + +function checkSchemaArray(value: Json, seen: WeakSet): boolean { + return Array.isArray(value) && value.every(entry => checkNode(entry, seen)); +} + +function checkSchemaMap(value: Json, seen: WeakSet): boolean { + if (!isPlainObject(value)) return false; + return Object.values(value).every(sub => checkNode(sub, seen)); +} + +/** Validate a single sub-schema node. */ function checkNode(node: Json, seen: WeakSet): boolean { // Boolean schemas (`true` / `false`) are valid JSON Schema. if (node === true || node === false) return true; @@ -35,56 +58,30 @@ function checkNode(node: Json, seen: WeakSet): boolean { if (seen.has(node)) return true; seen.add(node); - // `type` must be a known type name or a non-empty array of them. - if ("type" in node) { - const t = node.type; - if (typeof t === "string") { - if (!TYPE_NAMES.has(t)) return false; - } else if (Array.isArray(t)) { - if (t.length === 0) return false; - for (const entry of t) { - if (typeof entry !== "string" || !TYPE_NAMES.has(entry)) return false; - } - } else { - return false; - } - } + if ("type" in node && !checkTypeKeyword(node.type)) return false; - // Combinators must be arrays of sub-schemas. for (const key of ["anyOf", "oneOf", "allOf"] as const) { - if (key in node) { - const value = node[key]; - if (!Array.isArray(value)) return false; - for (const branch of value) { - if (!checkNode(branch, seen)) return false; - } - } + if (key in node && !checkSchemaArray(node[key], seen)) return false; } - - // `not` is a single sub-schema. if ("not" in node && !checkNode(node.not, seen)) return false; - // `properties` / `patternProperties` are objects of sub-schemas. for (const key of ["properties", "patternProperties", "$defs", "definitions"] as const) { - if (key in node) { - const map = node[key]; - if (!isPlainObject(map)) return false; - for (const sub of Object.values(map)) { - if (!checkNode(sub, seen)) return false; - } - } + if (key in node && !checkSchemaMap(node[key], seen)) return false; } - // `required` is an array of unique strings. + if ("propertyNames" in node && !checkNode(node.propertyNames, seen)) return false; + if ("contains" in node && !checkNode(node.contains, seen)) return false; + if ("required" in node) { const value = node.required; if (!Array.isArray(value)) return false; + const seenRequired = new Set(); for (const entry of value) { - if (typeof entry !== "string") return false; + if (typeof entry !== "string" || seenRequired.has(entry)) return false; + seenRequired.add(entry); } } - // `items` may be a sub-schema (single) or an array of sub-schemas (tuple). if ("items" in node) { const items = node.items; if (Array.isArray(items)) { @@ -93,31 +90,64 @@ function checkNode(node: Json, seen: WeakSet): boolean { } } else if (!checkNode(items, seen)) return false; } + if ("prefixItems" in node && !checkSchemaArray(node.prefixItems, seen)) return false; - // `additionalProperties` may be boolean or sub-schema. - if ("additionalProperties" in node) { - const value = node.additionalProperties; + for (const key of [ + "additionalProperties", + "additionalItems", + "unevaluatedProperties", + "unevaluatedItems", + ] as const) { + if (!(key in node)) continue; + const value = node[key]; if (typeof value !== "boolean" && !checkNode(value, seen)) return false; } - // `additionalItems` follows the same rule. - if ("additionalItems" in node) { - const value = node.additionalItems; - if (typeof value !== "boolean" && !checkNode(value, seen)) return false; + if ("dependentSchemas" in node && !checkSchemaMap(node.dependentSchemas, seen)) return false; + if ("dependentRequired" in node) { + const value = node.dependentRequired; + if (!isPlainObject(value)) return false; + for (const entry of Object.values(value)) { + if (!Array.isArray(entry) || !entry.every(item => typeof item === "string")) return false; + } } - // `enum` must be a non-empty array. if ("enum" in node) { - if (!Array.isArray(node.enum) || node.enum.length === 0) return false; + if (!Array.isArray(node.enum) || node.enum.length === 0 || !hasUniqueJsonValues(node.enum)) return false; } + for (const key of ["minimum", "maximum", "multipleOf"] as const) { + if (key in node && typeof node[key] !== "number") return false; + } + if (node.multipleOf !== undefined && typeof node.multipleOf === "number" && node.multipleOf <= 0) return false; + for (const key of ["exclusiveMinimum", "exclusiveMaximum"] as const) { + if (key in node && typeof node[key] !== "number" && typeof node[key] !== "boolean") return false; + } + for (const key of ["minLength", "maxLength", "minItems", "maxItems", "minProperties", "maxProperties"] as const) { + if (key in node && !isNonNegativeInteger(node[key])) return false; + } + for (const key of ["minContains", "maxContains"] as const) { + if (key in node && !isNonNegativeInteger(node[key])) return false; + } + if ("uniqueItems" in node && typeof node.uniqueItems !== "boolean") return false; + if ("pattern" in node) { + if (typeof node.pattern !== "string") return false; + try { + new RegExp(node.pattern); + } catch { + return false; + } + } + if ("format" in node && typeof node.format !== "string") return false; + if ("nullable" in node && typeof node.nullable !== "boolean") return false; + if ("readOnly" in node && typeof node.readOnly !== "boolean") return false; + if ("writeOnly" in node && typeof node.writeOnly !== "boolean") return false; + if ("deprecated" in node && typeof node.deprecated !== "boolean") return false; + return true; } -/** - * Validate that `schema` is structurally a valid JSON Schema (subset). Used - * in CCA-claude validation where the full AJV meta-check used to live. - */ +/** Validate that `schema` is structurally a valid JSON Schema (subset). */ export function isValidJsonSchema(schema: unknown): boolean { try { return checkNode(schema, new WeakSet()); diff --git a/packages/ai/src/utils/validation.ts b/packages/ai/src/utils/validation.ts index 9ce7017b2..eaee090fd 100644 --- a/packages/ai/src/utils/validation.ts +++ b/packages/ai/src/utils/validation.ts @@ -1,8 +1,12 @@ import { structuredCloneJSON } from "@oh-my-pi/pi-utils"; -import { type ZodType, z } from "zod/v4"; +import type { ZodType } from "zod/v4"; import type { $ZodIssue as ZodIssue } from "zod/v4/core"; import type { Tool, ToolCall } from "../types"; -import { fromTypeBox } from "./schema/from-typebox"; +import { + isJsonSchemaValueValid, + type JsonSchemaValidationIssue, + validateJsonSchemaValue, +} from "./schema/json-schema-validator"; import { isZodSchema, zodToWireSchema } from "./schema/wire"; // ============================================================================ @@ -473,23 +477,12 @@ function setValueAtPointer(root: unknown, pointer: string, value: unknown): unkn // ============================================================================ /** - * Resolve a JSON-Schema branch (used inside `anyOf`/`oneOf`) into a Zod - * schema we can probe for branch-membership during nullable-strip - * normalization. Cached so repeated traversals of the same schema reuse the - * compiled Zod schema. + * Test a JSON-Schema branch during nullable normalization. Kept deliberately + * small and synchronous so validation does not need to compile legacy schemas + * into another schema language. */ -const branchZodCache = new WeakMap(); -function branchAsZod(branch: unknown): ZodType | null { - if (!branch || typeof branch !== "object") return null; - let cached = branchZodCache.get(branch as object); - if (cached) return cached; - try { - cached = z.fromJSONSchema(branch as Parameters[0]) as ZodType; - } catch { - return null; - } - branchZodCache.set(branch as object, cached); - return cached; +function branchMatchesSchema(branch: unknown, value: unknown): boolean { + return isJsonSchemaValueValid(branch, value); } function normalizeOptionalNullsForSchema(schema: unknown, value: unknown): { value: unknown; changed: boolean } { @@ -508,8 +501,7 @@ function normalizeOptionalNullsForSchema(schema: unknown, value: unknown): { val const normalized = normalizeOptionalNullsForSchema(branch, value); if (!normalized.changed) continue; - const branchSchema = branchAsZod(branch); - if (branchSchema?.safeParse(normalized.value).success) { + if (branchMatchesSchema(branch, normalized.value)) { return normalized; } @@ -668,6 +660,8 @@ function mapZodExpectedToJsonSchemaType(expected: unknown): string | null { case "object": case "null": return expected; + case "record": + return "object"; case "int": case "bigint": return "integer"; @@ -754,13 +748,19 @@ function coerceArgsFromIssues(args: unknown, issues: FlatIssue[]): { value: unkn // Public API // ============================================================================ -interface ValidationContext { - zod: ZodType; - json: Record; -} +type ValidationContext = + | { + kind: "zod"; + zod: ZodType; + json: Record; + } + | { + kind: "json"; + json: Record; + }; /** - * Cache the (zod, json) pair derived from a tool's parameters schema. + * Cache the validation context derived from a tool's parameters schema. * Keyed by the parameters object identity, which is stable across tool * registrations. */ @@ -770,15 +770,61 @@ function getValidationContext(tool: Tool): ValidationContext { let ctx = validationContextCache.get(params); if (ctx) return ctx; if (isZodSchema(params)) { - ctx = { zod: params, json: zodToWireSchema(params) }; + ctx = { kind: "zod", zod: params, json: zodToWireSchema(params) }; } else { - const json = params as unknown as Record; - ctx = { zod: fromTypeBox(json), json }; + ctx = { kind: "json", json: params as unknown as Record }; } validationContextCache.set(params, ctx); return ctx; } +type ContextValidationResult = + | { success: true; value: unknown } + | { success: false; flatIssues: FlatIssue[]; messages: string[] }; + +function isPlainRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function preserveUnknownRootFields(input: unknown, parsed: unknown): unknown { + if (!isPlainRecord(input) || !isPlainRecord(parsed)) return parsed; + return { ...input, ...parsed }; +} + +function flattenJsonSchemaIssues(issues: ReadonlyArray): FlatIssue[] { + return issues.map(issue => ({ + keyword: issue.keyword === "type" ? "type" : "other", + instancePath: pathToPointer(issue.path), + expectedTypes: issue.expectedTypes ?? [], + })); +} + +function formatIssuePath(path: ReadonlyArray): string { + return path.length === 0 ? "root" : path.map(seg => String(seg)).join("/"); +} + +function validateContext(ctx: ValidationContext, value: unknown): ContextValidationResult { + if (ctx.kind === "zod") { + const result = ctx.zod.safeParse(value); + if (result.success) { + return { success: true, value: preserveUnknownRootFields(value, result.data) }; + } + return { + success: false, + flatIssues: flattenIssues(result.error.issues), + messages: result.error.issues.map(issue => ` - ${formatIssuePath(issue.path)}: ${issue.message}`), + }; + } + + const result = validateJsonSchemaValue(ctx.json, value); + if (result.success) return { success: true, value }; + return { + success: false, + flatIssues: flattenJsonSchemaIssues(result.issues), + messages: result.issues.map(issue => ` - ${formatIssuePath(issue.path)}: ${issue.message}`), + }; +} + const MAX_COERCION_PASSES = 5; /** @@ -797,15 +843,16 @@ export function validateToolCall(tools: Tool[], toolCall: ToolCall): ToolCall["a } /** - * Validates tool call arguments against the tool's schema (Zod, or TypeBox - * lifted into Zod). Applies LLM-quirk coercions (numeric strings, JSON-string + * Validates tool call arguments against the tool's schema (Zod or plain JSON + * Schema). Applies LLM-quirk coercions (numeric strings, JSON-string * containers, null-for-optional, null-for-default) before declaring failure. * * @throws Error with a formatted message when validation cannot be reconciled. */ export function validateToolArguments(tool: Tool, toolCall: ToolCall): ToolCall["arguments"] { const originalArgs = toolCall.arguments; - const { zod, json } = getValidationContext(tool); + const ctx = getValidationContext(tool); + const { json } = ctx; // Always normalize first — strip null and string "null" from optional // fields and substitute defaults. Handles LLM outputting string "null" @@ -818,12 +865,11 @@ export function validateToolArguments(tool: Tool, toolCall: ToolCall): ToolCall[ changed = true; } - let result = zod.safeParse(normalizedArgs); - if (result.success) return result.data as ToolCall["arguments"]; + let result = validateContext(ctx, normalizedArgs); + if (result.success) return result.value as ToolCall["arguments"]; for (let pass = 0; pass < MAX_COERCION_PASSES; pass += 1) { - const flat = flattenIssues(result.error.issues); - const coercion = coerceArgsFromIssues(normalizedArgs, flat); + const coercion = coerceArgsFromIssues(normalizedArgs, result.flatIssues); if (!coercion.changed) break; normalizedArgs = coercion.value; @@ -834,19 +880,13 @@ export function validateToolArguments(tool: Tool, toolCall: ToolCall): ToolCall[ normalizedArgs = nullNormalization.value; } - result = zod.safeParse(normalizedArgs); - if (result.success) return result.data as ToolCall["arguments"]; + result = validateContext(ctx, normalizedArgs); + if (result.success) return result.value as ToolCall["arguments"]; } // Format validation errors nicely. The header phrase is asserted by // existing tests; the detailed body is informational. - const errors = - result.error.issues - .map(issue => { - const path = issue.path.length === 0 ? "root" : issue.path.map(seg => String(seg)).join("/"); - return ` - ${path}: ${issue.message}`; - }) - .join("\n") || "Unknown validation error"; + const errors = result.messages.join("\n") || "Unknown validation error"; const receivedArgs = changed ? { diff --git a/packages/ai/test/azure-openai-responses-stream.test.ts b/packages/ai/test/azure-openai-responses-stream.test.ts index 0e799131e..416cbb044 100644 --- a/packages/ai/test/azure-openai-responses-stream.test.ts +++ b/packages/ai/test/azure-openai-responses-stream.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; import { type AzureOpenAIResponsesOptions, streamAzureOpenAIResponses } from "../src/providers/azure-openai-responses"; -import type { Context, Model } from "../src/types"; +import type { Context, Model, Tool } from "../src/types"; const originalFetch = global.fetch; @@ -133,6 +133,44 @@ describe("azure openai responses streaming", () => { expect(payload.cache_control).toBeUndefined(); }); + it("rewrites oneOf tool schemas to anyOf for Azure Responses", async () => { + const tool: Tool = { + name: "choose", + description: "choose a branch", + parameters: { + type: "object", + properties: { + item: { + oneOf: [ + { + type: "object", + properties: { kind: { const: "a" }, value: { type: "string" } }, + required: ["kind", "value"], + additionalProperties: false, + }, + { + type: "object", + properties: { kind: { const: "b" }, count: { type: "integer" } }, + required: ["kind", "count"], + additionalProperties: false, + }, + ], + }, + }, + required: ["item"], + }, + }; + + const payload = await captureAzurePayload({ + messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }], + tools: [tool], + }); + + const tools = payload.tools as Array<{ parameters: { properties: { item: Record } } }>; + expect(tools[0].parameters.properties.item.oneOf).toBeUndefined(); + expect(Array.isArray(tools[0].parameters.properties.item.anyOf)).toBe(true); + }); + it("surfaces nested response.failed provider errors", async () => { global.fetch = vi.fn(async () => createSseResponse([ diff --git a/packages/ai/test/google-tool-schema.test.ts b/packages/ai/test/google-tool-schema.test.ts index d704801a4..3d9792586 100644 --- a/packages/ai/test/google-tool-schema.test.ts +++ b/packages/ai/test/google-tool-schema.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "bun:test"; import { convertTools } from "@oh-my-pi/pi-ai/providers/google-shared"; import type { Model, TJsonSchema, Tool } from "@oh-my-pi/pi-ai/types"; -import { sanitizeSchemaForCCA, sanitizeSchemaForGoogle } from "@oh-my-pi/pi-ai/utils/schema"; +import { prepareSchemaForCCA, sanitizeSchemaForCCA, sanitizeSchemaForGoogle } from "@oh-my-pi/pi-ai/utils/schema"; function createModel(id: string): Model<"google-gemini-cli"> { return { @@ -47,6 +47,29 @@ describe("Cloud Code Assist Claude tool schema conversion", () => { }); }); + it("strips propertyNames before sending legacy CCA parameters", () => { + const schema = { + type: "object", + properties: { + env: { + type: "object", + propertyNames: { type: "string", pattern: "^[A-Z_]+$" }, + additionalProperties: { type: "string" }, + }, + }, + } as unknown; + + expect(sanitizeSchemaForCCA(schema)).toEqual({ + type: "object", + properties: { + env: { + type: "object", + properties: {}, + }, + }, + }); + }); + it("uses sanitized parameters for claude models with deterministic output", () => { const parameters = { type: "object", @@ -256,6 +279,22 @@ describe("Cloud Code Assist Claude tool schema conversion", () => { properties: {}, }); }); + + it("falls back when CCA schema meta-validation catches malformed keywords", () => { + const parameters = { + type: "object", + properties: { + mode: { type: "string", enum: ["read", "read"] }, + tags: { type: "array", items: { type: "string" }, uniqueItems: "true" }, + }, + required: ["mode"], + } as unknown; + + expect(prepareSchemaForCCA(parameters)).toEqual({ + type: "object", + properties: {}, + }); + }); it("keeps google sanitizer behavior for non-claude schema path", () => { const schema = { type: "object", diff --git a/packages/ai/test/tool-argument-coercion.test.ts b/packages/ai/test/tool-argument-coercion.test.ts index da7882dec..1a807336e 100644 --- a/packages/ai/test/tool-argument-coercion.test.ts +++ b/packages/ai/test/tool-argument-coercion.test.ts @@ -78,6 +78,76 @@ describe("Tool argument coercion", () => { expect(result.payload).toEqual({ a: 1 }); }); + it("preserves unknown root fields after Zod validation so tools can reject disabled arguments", () => { + const tool: Tool = { + name: "t4b", + description: "", + parameters: z.object({ command: z.string() }), + }; + + const result = validateToolArguments(tool, { + type: "toolCall", + id: "call-4b", + name: "t4b", + arguments: { command: "echo hi", async: true }, + }); + + expect(result).toEqual({ command: "echo hi", async: true }); + }); + + it("coerces JSON-stringified records emitted for Zod record fields", () => { + const tool: Tool = { + name: "t4c", + description: "", + parameters: z.object({ env: z.record(z.string(), z.string()) }), + }; + + const result = validateToolArguments(tool, { + type: "toolCall", + id: "call-4c", + name: "t4c", + arguments: { env: '{"FOO":"bar"}' }, + }); + + expect(result).toEqual({ env: { FOO: "bar" } }); + }); + + it("validates legacy draft-07 JSON Schema without converting it through Zod", () => { + const tool: Tool = { + name: "legacy_schema", + description: "", + parameters: { + type: "object", + properties: { + item: { $ref: "#/definitions/Item" }, + name: { type: "string", nullable: true }, + ids: { type: "array", items: { type: "string" }, uniqueItems: true }, + }, + required: ["item", "name", "ids"], + definitions: { + Item: { type: "string" }, + }, + }, + }; + + const valid = validateToolArguments(tool, { + type: "toolCall", + id: "call-legacy-ok", + name: "legacy_schema", + arguments: { item: "ok", name: null, ids: ["a", "b"] }, + }); + expect(valid).toEqual({ item: "ok", name: null, ids: ["a", "b"] }); + + expect(() => + validateToolArguments(tool, { + type: "toolCall", + id: "call-legacy-bad", + name: "legacy_schema", + arguments: { item: "ok", name: null, ids: ["a", "a"] }, + }), + ).toThrow("unique"); + }); + it("parses nested JSON arrays in string values", () => { const tool: Tool = { name: "t5", diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 480292840..64c56aa7b 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,19 +1,27 @@ # Changelog ## [Unreleased] + ### Breaking Changes - Changed the extension and hook runtime API by moving schema typing from direct TypeBox imports to `TSchema` from `@oh-my-pi/pi-ai`, requiring callers who use TypeScript imports of `Type` to migrate via provided injected modules ### Added +- Added `apiKey` as a supported provider override field in model config, allowing API-key-only overrides to provide fallback credentials for built-in models +- Added `supportsMultipleSystemMessages`, `allowsSyntheticReasoningContentForToolCalls`, `disableReasoningOnToolChoice`, and `levels` model-thinking compatibility fields to model configuration schemas - Added `zod` to the Extension, Custom Tool, Hook, and Custom Command APIs as `pi.zod` so extension and plugin authors can define tool schemas with Zod without separate imports - Added `pi.zod` as a canonical schema API for examples and extension plugins while keeping `typebox` available as legacy compatibility - Added a `telemetry` option to `createAgentSession` for passing OpenTelemetry configuration through to the underlying Agent ### Changed -- Changed tool parameter schemas across the agent to use the shared Pi schema pipeline (`TSchema` and `fromTypeBox`) instead of direct AJV/TypeBox compilation for stricter schema validation compatibility +- Changed `pi.typebox.Type.Composite` to merge all object schemas in the provided list, enabling more than two object inputs +- Changed `pi.typebox.Type.Record` to validate record keys against the provided key schema instead of forcing string keys +- Changed `pi.typebox.Type.Array` with `uniqueItems: true` to reject duplicate items while preserving the constraint in wire schemas +- Changed `pi.typebox.Type.Object` with `additionalProperties: false` to reject unknown properties during parsing +- Changed `pi.typebox.Type.Enum` in the compatibility shim to preserve numeric TypeScript enum values +- Changed tool parameter schemas across the agent to use the shared Pi schema pipeline (`TSchema` plus Zod/JSON Schema validation) instead of direct AJV/TypeBox compilation for stricter schema validation compatibility - Changed GitHub tool input schema shape to expose operation fields in a flat schema form without legacy `run_watch`-style nesting - Changed Python session pooling to remove the previous 4-session retention cap and 5-minute idle-session eviction, so kernels now stay alive for a session until explicitly disposed via `disposeKernelSessionsByOwner` or `disposeAllKernelSessions` - Changed kernel cleanup behavior to avoid automatic eviction by idle timeout and capacity pressure, so additional Python sessions are not queued behind retained-session shutdown retries @@ -25,14 +33,14 @@ ### Fixed +- Fixed `create_conventional_analysis` parsing to ignore harmless extra fields and still parse the required conventional fields +- Fixed BashTool async request validation flow so async execution remains disabled and returns the explicit `Async bash execution is disabled` error +- Fixed `task.simple` invalid `schema` and `context` argument handling to still reject unsupported fields after tool-argument validation - Fixed subagent execution hangs by enforcing `task.maxRuntimeMs` as a wall-clock limit even when inference streaming stalls, so stuck subagents now abort and report runtime-limit exceeded - Fixed tool schema compatibility validation by routing TypeBox schemas through shared conversion and Zod-based validation to avoid strict-schema provider mismatches - Fixed Python execution cancellation and timeouts by escalating to kernel shutdown if `SIGINT` did not terminate a running cell within 2 seconds, preventing indefinite hangs in queued or stuck sessions - Fixed cleanup blocking during long-running executions by forcing a kernel shutdown path when interrupt-based cancellation is ignored - Fixed bash output emitting a spurious `[… 0 lines elided (NB) …]` marker (and reordering the artifact link before the command output) when the shell minimizer rewrote a small command's output. After `OutputSink.replace()` swapped the minimized text into the buffer, the subsequent `sink.push("[raw output: artifact://N]\n")` chunk was funneled back into the (now empty) head-retention window while the pre-replace `#totalBytes` still tracked the original raw stream — so `dump()` composed ` + + ` instead of ` + `. `replace()` now realigns `#totalBytes`/`#totalLines`/`#sawData`/`#truncated` to the authoritative buffer and disables head retention for the lifetime of the sink, so further pushes append to the tail buffer in order. The bash executor also drops the leading `\n` on the artifact-link push when the minimized text already ends with one so the separator stays single-newline. - -### Fixed - - Fixed legacy plugin extensions failing to load on Windows when they import a bare-specifier dependency from their own `node_modules` (e.g. `import YAML from "yaml"` in `supipowers`). The legacy-pi mirror resolved the dependency to its absolute path and then ran the path through `isUrlLikeSpecifier`, whose `^[A-Za-z][A-Za-z\d+.-]*:` regex matched the Windows drive letter (`C:`) and short-circuited the `pathToFileURL` conversion. The raw path was emitted into the mirrored TS source as `import x from "C:\\Users\\...\\dep\\dist\\index.js"`, where `\n`, `\U`, `\y` and other backslash sequences were eaten by the TS string-literal parser, producing nonsense package specifiers like `C:Usersjames.ompagentextensionssupipowers\node_modulesyamldistindex.js` that Bun's resolver rejected with `Cannot find package …`. `isUrlLikeSpecifier` now rejects `^[A-Za-z]:[\\/]` first, so Windows absolute paths flow through `pathToFileURL` like every other absolute path and reach the mirror as proper `file:///C:/...` URLs. ## [15.0.2] - 2026-05-15 diff --git a/packages/coding-agent/src/commit/shared-llm.ts b/packages/coding-agent/src/commit/shared-llm.ts index 8d35955e4..fd48487c5 100644 --- a/packages/coding-agent/src/commit/shared-llm.ts +++ b/packages/coding-agent/src/commit/shared-llm.ts @@ -20,20 +20,18 @@ const changelogCategoryLiteral = z.enum([ * are identical across phases — only the surrounding tool `description` * differs to reflect the input the phase is summarizing. */ -export const conventionalAnalysisParameters = z - .object({ - type: z.enum(["feat", "fix", "refactor", "docs", "test", "chore", "style", "perf", "build", "ci", "revert"]), - scope: z.union([z.string(), z.null()]), - details: z.array( - z.object({ - text: z.string(), - changelog_category: changelogCategoryLiteral.optional(), - user_visible: z.boolean().optional(), - }), - ), - issue_refs: z.array(z.string()), - }) - .strict(); +export const conventionalAnalysisParameters = z.object({ + type: z.enum(["feat", "fix", "refactor", "docs", "test", "chore", "style", "perf", "build", "ci", "revert"]), + scope: z.union([z.string(), z.null()]), + details: z.array( + z.object({ + text: z.string(), + changelog_category: changelogCategoryLiteral.optional(), + user_visible: z.boolean().optional(), + }), + ), + issue_refs: z.array(z.string()), +}); export interface ConventionalAnalysisTool { name: "create_conventional_analysis"; diff --git a/packages/coding-agent/src/config/model-registry.ts b/packages/coding-agent/src/config/model-registry.ts index e7d1710cc..1a8dd41ee 100644 --- a/packages/coding-agent/src/config/model-registry.ts +++ b/packages/coding-agent/src/config/model-registry.ts @@ -165,12 +165,13 @@ function validateProviderConfiguration( !config.baseUrl && !config.headers && !config.compat && + !config.apiKey && !config.disableStrictTools && !hasModelOverrides && !config.discovery ) { throw new Error( - `Provider ${providerName}: must specify "baseUrl", "headers", "compat", "disableStrictTools", "modelOverrides", "discovery", or "models"`, + `Provider ${providerName}: must specify "baseUrl", "headers", "apiKey", "compat", "disableStrictTools", "modelOverrides", "discovery", or "models"`, ); } } diff --git a/packages/coding-agent/src/config/models-config-schema.ts b/packages/coding-agent/src/config/models-config-schema.ts index 29c136618..9b3802bcf 100644 --- a/packages/coding-agent/src/config/models-config-schema.ts +++ b/packages/coding-agent/src/config/models-config-schema.ts @@ -21,6 +21,7 @@ const ReasoningEffortMapSchema = z.object({ export const OpenAICompatSchema = z.object({ supportsStore: z.boolean().optional(), supportsDeveloperRole: z.boolean().optional(), + supportsMultipleSystemMessages: z.boolean().optional(), supportsReasoningEffort: z.boolean().optional(), reasoningEffortMap: ReasoningEffortMapSchema.optional(), maxTokensField: z.enum(["max_completion_tokens", "max_tokens"]).optional(), @@ -31,9 +32,11 @@ export const OpenAICompatSchema = z.object({ requiresThinkingAsText: z.boolean().optional(), reasoningContentField: z.enum(["reasoning_content", "reasoning", "reasoning_text"]).optional(), requiresReasoningContentForToolCalls: z.boolean().optional(), + allowsSyntheticReasoningContentForToolCalls: z.boolean().optional(), requiresAssistantContentForToolCalls: z.boolean().optional(), supportsToolChoice: z.boolean().optional(), disableReasoningOnForcedToolChoice: z.boolean().optional(), + disableReasoningOnToolChoice: z.boolean().optional(), thinkingFormat: z.enum(["openai", "openrouter", "zai", "qwen", "qwen-chat-template"]).optional(), openRouterRouting: OpenRouterRoutingSchema.optional(), vercelGatewayRouting: VercelGatewayRoutingSchema.optional(), @@ -57,6 +60,7 @@ const ModelThinkingSchema = z.object({ maxLevel: EffortSchema, mode: ThinkingControlModeSchema, defaultLevel: EffortSchema.optional(), + levels: z.array(EffortSchema).optional(), }); const ModelDefinitionSchema = z.object({ diff --git a/packages/coding-agent/src/extensibility/typebox.ts b/packages/coding-agent/src/extensibility/typebox.ts index 02d3387f0..6768be442 100644 --- a/packages/coding-agent/src/extensibility/typebox.ts +++ b/packages/coding-agent/src/extensibility/typebox.ts @@ -21,6 +21,7 @@ * `@sinclair/typebox` directly in their own package. */ +import { areJsonValuesEqual } from "@oh-my-pi/pi-ai/utils/schema"; import { type ZodArray, type ZodEnum, @@ -105,6 +106,13 @@ function withMeta(schema: T, opts: Meta | undefined): T { let out: ZodType = schema; if (typeof opts.description === "string") out = out.describe(opts.description); if ("default" in opts) out = out.default(opts.default as never) as unknown as ZodType; + + const metadata: Record = {}; + for (const [key, value] of Object.entries(opts)) { + if (key === "description" || key === "default" || key === "additionalProperties") continue; + metadata[key] = value; + } + if (Object.keys(metadata).length > 0) out = out.meta(metadata); return out as T; } @@ -213,22 +221,59 @@ function tIntersect(schemas: readonly ZodType[], opts?: Meta): ZodType { return withMeta(out, opts); } -function tEnum>(values: T, opts?: Meta): ZodType { - // Accepts either a plain object (TS enum / record of name→value) or a - // pre-built array; both are tolerated by `z.enum`. We collapse to values - // because TypeBox's `Type.Enum` discards the keys for the JSON Schema. - const list = Array.isArray(values) ? (values as unknown as (string | number)[]) : Object.values(values); - return withMeta(z.enum(list as [string, ...string[]]), opts); +function isArrayIndexKey(key: string): boolean { + if (!/^(?:0|[1-9]\\d*)$/.test(key)) return false; + const index = Number(key); + return Number.isSafeInteger(index) && index >= 0; +} + +function uniqueLiteralValues(values: readonly (string | number | boolean)[]): Array { + const unique: Array = []; + for (const value of values) { + if (!unique.some(existing => existing === value)) unique.push(value); + } + return unique; +} + +function literalUnion(values: readonly (string | number | boolean)[], opts?: Meta): ZodType { + const unique = uniqueLiteralValues(values); + if (unique.length === 0) return withMeta(z.never(), opts); + if (unique.length === 1) return withMeta(z.literal(unique[0] as string | number | boolean), opts); + const schemas = unique.map(value => z.literal(value as string | number | boolean)) as unknown as [ + ZodType, + ZodType, + ...ZodType[], + ]; + return withMeta(z.union(schemas), opts); +} +function tEnum | readonly (string | number)[]>( + values: T, + opts?: Meta, +): ZodType { + const list = Array.isArray(values) + ? values + : Object.entries(values) + .filter(([key, value]) => !(isArrayIndexKey(key) && typeof value === "string")) + .map(([, value]) => value); + return literalUnion(list, opts); } function tArray(item: E, opts?: ArrayOpts): ZodType { - let arr = z.array(item); + let arr: ZodType = z.array(item); if (opts) { - if (typeof opts.minItems === "number") arr = arr.min(opts.minItems); - if (typeof opts.maxItems === "number") arr = arr.max(opts.maxItems); - // `uniqueItems` is observably useful only at JSON Schema emit time — - // providers either honor it or ignore it. Zod has no native equivalent, - // so we encode it as schema metadata for the wire output to surface. + if (typeof opts.minItems === "number") arr = (arr as ZodArray).min(opts.minItems); + if (typeof opts.maxItems === "number") arr = (arr as ZodArray).max(opts.maxItems); + if (opts.uniqueItems === true) { + arr = arr.refine(items => { + if (!Array.isArray(items)) return true; + for (let i = 0; i < items.length; i += 1) { + for (let j = i + 1; j < items.length; j += 1) { + if (areJsonValuesEqual(items[i], items[j])) return false; + } + } + return true; + }, "Expected array items to be unique"); + } } return withMeta(arr, opts); } @@ -249,9 +294,7 @@ function tObject

(properties: P, opts?: ObjectOpts): ZodOb let obj = z.object(properties); if (opts && opts.additionalProperties !== undefined) { if (opts.additionalProperties === false) { - // `.strict()` would *reject* extra keys; for parity with the looser - // real-TypeBox behavior we keep the default (strip-on-parse) which - // still serializes to `additionalProperties: false`. + obj = obj.strict() as unknown as ZodObject

; } else if (opts.additionalProperties === true) { obj = obj.catchall(z.any()) as unknown as ZodObject

; } else { @@ -261,11 +304,8 @@ function tObject

(properties: P, opts?: ObjectOpts): ZodOb return withMeta(obj, opts); } -function tRecord(_key: ZodType, value: V, opts?: Meta): ZodType { - // JSON Schema `Type.Record(K, V)` is always keyed by strings on the wire - // (no provider honors numeric keys), so we ignore the key schema beyond - // the implicit string constraint. - return withMeta(z.record(z.string(), value) as unknown as ZodType, opts); +function tRecord(key: ZodType, value: V, opts?: Meta): ZodType { + return withMeta(z.record(key as never, value as never) as unknown as ZodType, opts); } function tOptional(schema: E, _opts?: Meta): ZodOptional { @@ -299,13 +339,15 @@ function tOmit

(obj: ZodObject

, keys return obj.omit(mask as never) as unknown as ZodObject>; } -function tComposite( - objects: readonly [ZodObject, ZodObject], -): ZodObject { - // `Type.Composite([A, B])` flattens objects into a single object schema - // rather than producing an intersection. Mirror that via Zod's extend. - const [a, b] = objects; - return a.extend(b.shape) as unknown as ZodObject; +function tComposite(objects: readonly ZodObject[], opts?: Meta): ZodObject { + // `Type.Composite([...])` flattens every object schema into one object schema + // rather than producing an intersection. Mirror that via repeated `extend`. + if (objects.length === 0) return withMeta(z.object({}), opts) as ZodObject; + let out = objects[0] as ZodObject; + for (let i = 1; i < objects.length; i += 1) { + out = out.extend(objects[i].shape) as ZodObject; + } + return withMeta(out, opts) as ZodObject; } // --------------------------------------------------------------------------- diff --git a/packages/coding-agent/src/task/executor.ts b/packages/coding-agent/src/task/executor.ts index 9685c3ec3..b4ac92b5f 100644 --- a/packages/coding-agent/src/task/executor.ts +++ b/packages/coding-agent/src/task/executor.ts @@ -7,7 +7,7 @@ import path from "node:path"; import type { AgentEvent, AgentIdentity, AgentTelemetryConfig, ThinkingLevel } from "@oh-my-pi/pi-agent-core"; import { recordHandoff, resolveTelemetry } from "@oh-my-pi/pi-agent-core"; -import { fromTypeBox } from "@oh-my-pi/pi-ai/utils/schema"; +import { isJsonSchemaValueValid } from "@oh-my-pi/pi-ai/utils/schema"; import { logger, prompt, untilAborted } from "@oh-my-pi/pi-utils"; import { ModelRegistry } from "../config/model-registry"; import { resolveModelOverrideWithAuthFallback } from "../config/model-resolver"; @@ -209,12 +209,7 @@ function buildOutputValidator(schema: unknown): { validate?: (value: unknown) => if (error) return { error }; if (normalized === undefined) return {}; const jsonSchema = jtdToJsonSchema(normalized); - try { - const zod = fromTypeBox(jsonSchema); - return { validate: value => zod.safeParse(value).success }; - } catch (err) { - return { error: err instanceof Error ? err.message : String(err) }; - } + return { validate: value => isJsonSchemaValueValid(jsonSchema, value) }; } function tryParseJsonOutput(text: string): unknown | undefined { diff --git a/packages/coding-agent/src/tools/yield.ts b/packages/coding-agent/src/tools/yield.ts index d5cb85c3c..5ba8ab942 100644 --- a/packages/coding-agent/src/tools/yield.ts +++ b/packages/coding-agent/src/tools/yield.ts @@ -5,9 +5,14 @@ */ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; import type { TSchema } from "@oh-my-pi/pi-ai/types"; -import { dereferenceJsonSchema, fromTypeBox, sanitizeSchemaForStrictMode } from "@oh-my-pi/pi-ai/utils/schema"; -import type { ZodType } from "zod/v4"; -import type { $ZodIssue as ZodIssue } from "zod/v4/core"; +import { + dereferenceJsonSchema, + isValidJsonSchema, + type JsonSchemaValidationIssue, + type JsonSchemaValidationResult, + sanitizeSchemaForStrictMode, + validateJsonSchemaValue, +} from "@oh-my-pi/pi-ai/utils/schema"; import { subprocessToolRegistry } from "../task/subprocess-tool-registry"; import type { ToolSession } from "."; import { jtdToJsonSchema, normalizeSchema } from "./jtd-to-json-schema"; @@ -28,12 +33,12 @@ function formatSchema(schema: unknown): string { } } -function formatZodIssues(issues: ReadonlyArray | undefined): string { +function formatJsonSchemaIssues(issues: ReadonlyArray | undefined): string { if (!issues || issues.length === 0) return "Unknown schema validation error."; return issues .map(issue => { const path = issue.path.length === 0 ? "" : `${issue.path.map(seg => String(seg)).join("/")}: `; - return `${path}${issue.message ?? "invalid"}`; + return `${path}${issue.message}`; }) .join("; "); } @@ -88,11 +93,11 @@ export class YieldTool implements AgentTool { readonly intent = "omit" as const; lenientArgValidation = true; - readonly #validate?: ZodType; + readonly #validate?: (value: unknown) => JsonSchemaValidationResult; #schemaValidationFailures = 0; constructor(session: ToolSession) { - let validate: ZodType | undefined; + let validate: ((value: unknown) => JsonSchemaValidationResult) | undefined; let parameters: TSchema; try { @@ -106,10 +111,10 @@ export class YieldTool implements AgentTool { } if (normalizedSchema !== undefined && normalizedSchema !== false && !schemaError) { - try { - validate = fromTypeBox(normalizedSchema as Record | boolean); - } catch (err) { - schemaError = err instanceof Error ? err.message : String(err); + if (!isValidJsonSchema(normalizedSchema)) { + schemaError = "invalid JSON schema"; + } else { + validate = value => validateJsonSchemaValue(normalizedSchema, value); } } @@ -141,7 +146,7 @@ export class YieldTool implements AgentTool { } parameters = wrapYieldParameters(dataSchema); JSON.stringify(parameters); - fromTypeBox(parameters as Record); + if (!isValidJsonSchema(parameters)) throw new Error("yield parameters schema is invalid"); } catch (err) { const errorMsg = err instanceof Error ? err.message : String(err); parameters = wrapYieldParameters( @@ -188,11 +193,11 @@ export class YieldTool implements AgentTool { throw new Error("data is required when yield indicates success"); } if (this.#validate) { - const parsed = this.#validate.safeParse(data); + const parsed = this.#validate(data); if (!parsed.success) { this.#schemaValidationFailures++; if (this.#schemaValidationFailures <= 1) { - throw new Error(`Output does not match schema: ${formatZodIssues(parsed.error.issues)}`); + throw new Error(`Output does not match schema: ${formatJsonSchemaIssues(parsed.issues)}`); } schemaValidationOverridden = true; } diff --git a/packages/coding-agent/test/commit-shared-llm.test.ts b/packages/coding-agent/test/commit-shared-llm.test.ts new file mode 100644 index 000000000..f30f54f9f --- /dev/null +++ b/packages/coding-agent/test/commit-shared-llm.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "bun:test"; +import type { AssistantMessage } from "@oh-my-pi/pi-ai"; +import { createConventionalAnalysisTool, parseConventionalAnalysisResponse } from "../src/commit/shared-llm"; + +describe("commit shared LLM parsing", () => { + it("ignores harmless extra fields in conventional analysis tool output", () => { + const tool = createConventionalAnalysisTool("Analyze a diff."); + const message = { + role: "assistant", + content: [ + { + type: "toolCall", + id: "call-analysis", + name: tool.name, + arguments: { + type: "fix", + scope: null, + details: [], + issue_refs: [], + summary: "fix: handle parser edge case", + }, + }, + ], + } as unknown as AssistantMessage; + + expect(parseConventionalAnalysisResponse(message, tool)).toEqual({ + type: "fix", + scope: null, + details: [], + issueRefs: [], + }); + }); +}); diff --git a/packages/coding-agent/test/extensibility/typebox-shim.test.ts b/packages/coding-agent/test/extensibility/typebox-shim.test.ts new file mode 100644 index 000000000..f1e095564 --- /dev/null +++ b/packages/coding-agent/test/extensibility/typebox-shim.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "bun:test"; +import { toolWireSchema } from "@oh-my-pi/pi-ai/utils/schema"; +import { Type } from "../../src/extensibility/typebox"; + +describe("pi.typebox compatibility shim", () => { + it("rejects extra properties when additionalProperties is false", () => { + const schema = Type.Object({ path: Type.String() }, { additionalProperties: false }); + + expect(schema.safeParse({ path: "README.md" }).success).toBe(true); + expect(schema.safeParse({ path: "README.md", mode: "delete" }).success).toBe(false); + }); + + it("preserves numeric enum values from TypeScript enum objects", () => { + const schema = Type.Enum({ 0: "Fast", 1: "Slow", Fast: 0, Slow: 1 }); + + expect(schema.safeParse(0).success).toBe(true); + expect(schema.safeParse(1).success).toBe(true); + expect(schema.safeParse("Fast").success).toBe(false); + }); + + it("enforces and emits uniqueItems for arrays", () => { + const schema = Type.Array(Type.String(), { uniqueItems: true }); + const wire = toolWireSchema({ name: "files", description: "", parameters: schema }); + + expect(schema.safeParse(["a.ts", "b.ts"]).success).toBe(true); + expect(schema.safeParse(["a.ts", "a.ts"]).success).toBe(false); + expect(wire.uniqueItems).toBe(true); + }); + + it("respects record key schemas", () => { + const schema = Type.Record(Type.Literal("target"), Type.String()); + + expect(schema.safeParse({ target: "ok" }).success).toBe(true); + expect(schema.safeParse({ other: "bad" }).success).toBe(false); + }); + + it("merges every object passed to Composite", () => { + const schema = Type.Composite([ + Type.Object({ a: Type.String() }), + Type.Object({ b: Type.String() }), + Type.Object({ c: Type.String() }), + ]); + + expect(schema.safeParse({ a: "a", b: "b", c: "c" }).success).toBe(true); + expect(schema.safeParse({ a: "a", b: "b" }).success).toBe(false); + }); +}); diff --git a/packages/coding-agent/test/model-registry.test.ts b/packages/coding-agent/test/model-registry.test.ts index db3cb1a03..911d96666 100644 --- a/packages/coding-agent/test/model-registry.test.ts +++ b/packages/coding-agent/test/model-registry.test.ts @@ -488,6 +488,26 @@ describe("ModelRegistry", () => { } }); + test("apiKey-only override supplies fallback auth for built-in models", async () => { + const originalOpenAiKey = Bun.env.OPENAI_API_KEY; + delete Bun.env.OPENAI_API_KEY; + try { + writeRawModelsJson({ + openai: { + apiKey: "issue-typed-key", + }, + }); + + const registry = new ModelRegistry(authStorage, modelsJsonPath); + const openaiModels = getModelsForProvider(registry, "openai"); + + expect(openaiModels.length).toBeGreaterThan(0); + await expect(registry.getApiKey(openaiModels[0])).resolves.toBe("issue-typed-key"); + } finally { + if (originalOpenAiKey === undefined) delete Bun.env.OPENAI_API_KEY; + else Bun.env.OPENAI_API_KEY = originalOpenAiKey; + } + }); test("baseUrl-only override does not affect other providers", () => { writeRawModelsJson({ anthropic: overrideConfig("https://my-proxy.example.com/v1"), @@ -551,6 +571,9 @@ describe("ModelRegistry", () => { compat: { supportsUsageInStreaming: false, supportsStrictMode: false, + supportsMultipleSystemMessages: false, + disableReasoningOnToolChoice: true, + allowsSyntheticReasoningContentForToolCalls: false, }, }, }); @@ -561,6 +584,9 @@ describe("ModelRegistry", () => { for (const model of models) { expect(getOpenAICompat(model)?.supportsUsageInStreaming).toBe(false); expect(getOpenAICompat(model)?.supportsStrictMode).toBe(false); + expect(getOpenAICompat(model)?.supportsMultipleSystemMessages).toBe(false); + expect(getOpenAICompat(model)?.disableReasoningOnToolChoice).toBe(true); + expect(getOpenAICompat(model)?.allowsSyntheticReasoningContentForToolCalls).toBe(false); } }); @@ -1043,6 +1069,7 @@ describe("ModelRegistry", () => { mode: "anthropic-adaptive", minLevel: Effort.Minimal, maxLevel: Effort.High, + levels: [Effort.Minimal, Effort.High], }; writeModelsJson({ diff --git a/packages/coding-agent/test/tools/bash-interceptor.test.ts b/packages/coding-agent/test/tools/bash-interceptor.test.ts index 161b3a766..3f45ffd1e 100644 --- a/packages/coding-agent/test/tools/bash-interceptor.test.ts +++ b/packages/coding-agent/test/tools/bash-interceptor.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "bun:test"; import type { AgentToolContext } from "@oh-my-pi/pi-agent-core"; +import { validateToolArguments } from "@oh-my-pi/pi-ai/utils/validation"; import type { BashInterceptorRule } from "../../src/config/settings-schema"; import type { ToolSession } from "../../src/tools"; import { BashTool } from "../../src/tools/bash"; @@ -57,6 +58,20 @@ describe("BashTool interception", () => { }); }); +describe("BashTool argument validation", () => { + it("preserves async requests so disabled async mode returns the explicit error", async () => { + const tool = createBashTool([]); + const args = validateToolArguments(tool, { + type: "toolCall", + id: "tool-call", + name: tool.name, + arguments: { command: "echo should-not-run", async: true }, + }); + + await expect(tool.execute("tool-call", args)).rejects.toThrow("Async bash execution is disabled"); + }); +}); + describe("BashTool head/tail stripping", () => { function createBashToolWithStrip(stripEnabled: boolean): BashTool { const session = { diff --git a/packages/coding-agent/test/tools/task-simple-mode.test.ts b/packages/coding-agent/test/tools/task-simple-mode.test.ts index bde43b149..3b76a8aa9 100644 --- a/packages/coding-agent/test/tools/task-simple-mode.test.ts +++ b/packages/coding-agent/test/tools/task-simple-mode.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; import { toolWireSchema } from "@oh-my-pi/pi-ai/utils/schema"; +import { validateToolArguments } from "@oh-my-pi/pi-ai/utils/validation"; import { Settings } from "../../src/config/settings"; import { TaskTool } from "../../src/task"; import * as discoveryModule from "../../src/task/discovery"; @@ -93,6 +94,18 @@ describe("task.simple", () => { tasks: [{ id: "One", description: "label", assignment: "Do the thing." }], } as TaskParams); expect(getFirstText(schemaFreeResult)).toContain("does not accept `schema`"); + const validatedSchemaFreeParams = validateToolArguments(schemaFreeTool, { + type: "toolCall", + id: "tool-1-validated", + name: schemaFreeTool.name, + arguments: { + agent: "task", + schema: '{"properties":{"ok":{"type":"boolean"}}}', + tasks: [{ id: "One", description: "label", assignment: "Do the thing." }], + }, + }); + const validatedSchemaFreeResult = await schemaFreeTool.execute("tool-1-validated", validatedSchemaFreeParams); + expect(getFirstText(validatedSchemaFreeResult)).toContain("does not accept `schema`"); const independentTool = await TaskTool.create(createSession({ "task.simple": "independent" })); const independentResult = await independentTool.execute("tool-2", { @@ -101,5 +114,17 @@ describe("task.simple", () => { tasks: [{ id: "Two", description: "label", assignment: "Do the independent thing." }], } as TaskParams); expect(getFirstText(independentResult)).toContain("does not accept `context`"); + const validatedIndependentParams = validateToolArguments(independentTool, { + type: "toolCall", + id: "tool-2-validated", + name: independentTool.name, + arguments: { + agent: "task", + context: "Shared background", + tasks: [{ id: "Two", description: "label", assignment: "Do the independent thing." }], + }, + }); + const validatedIndependentResult = await independentTool.execute("tool-2-validated", validatedIndependentParams); + expect(getFirstText(validatedIndependentResult)).toContain("does not accept `context`"); }); });