diff --git a/src/agents/openai-strict-tool-setting.ts b/src/agents/openai-strict-tool-setting.ts new file mode 100644 index 00000000000..af6d52f8b15 --- /dev/null +++ b/src/agents/openai-strict-tool-setting.ts @@ -0,0 +1,54 @@ +import { readStringValue } from "../shared/string-coerce.js"; +import { resolveProviderRequestCapabilities } from "./provider-attribution.js"; + +type OpenAITransportKind = "stream" | "websocket"; + +type OpenAIStrictToolModel = { + provider?: unknown; + api?: unknown; + baseUrl?: unknown; + id?: unknown; + compat?: { supportsStore?: boolean }; +}; + +const optionalString = readStringValue; + +export function resolvesToNativeOpenAIStrictTools( + model: OpenAIStrictToolModel, + transport: OpenAITransportKind, +): boolean { + const capabilities = resolveProviderRequestCapabilities({ + provider: optionalString(model.provider), + api: optionalString(model.api), + baseUrl: optionalString(model.baseUrl), + capability: "llm", + transport, + modelId: optionalString(model.id), + compat: + model.compat && typeof model.compat === "object" + ? (model.compat as { supportsStore?: boolean }) + : undefined, + }); + if (!capabilities.usesKnownNativeOpenAIRoute) { + return false; + } + return ( + capabilities.provider === "openai" || + capabilities.provider === "openai-codex" || + capabilities.provider === "azure-openai" || + capabilities.provider === "azure-openai-responses" + ); +} + +export function resolveOpenAIStrictToolSetting( + model: OpenAIStrictToolModel, + options?: { transport?: OpenAITransportKind; supportsStrictMode?: boolean }, +): boolean | undefined { + if (resolvesToNativeOpenAIStrictTools(model, options?.transport ?? "stream")) { + return true; + } + if (options?.supportsStrictMode) { + return false; + } + return undefined; +} diff --git a/src/agents/openai-text-verbosity.ts b/src/agents/openai-text-verbosity.ts new file mode 100644 index 00000000000..cd5ba92274c --- /dev/null +++ b/src/agents/openai-text-verbosity.ts @@ -0,0 +1,27 @@ +import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; +import { log } from "./pi-embedded-runner/logger.js"; + +export type OpenAITextVerbosity = "low" | "medium" | "high"; + +function normalizeOpenAITextVerbosity(value: unknown): OpenAITextVerbosity | undefined { + if (typeof value !== "string") { + return undefined; + } + const normalized = normalizeOptionalLowercaseString(value); + if (normalized === "low" || normalized === "medium" || normalized === "high") { + return normalized; + } + return undefined; +} + +export function resolveOpenAITextVerbosity( + extraParams: Record | undefined, +): OpenAITextVerbosity | undefined { + const raw = extraParams?.textVerbosity ?? extraParams?.text_verbosity; + const normalized = normalizeOpenAITextVerbosity(raw); + if (raw !== undefined && normalized === undefined) { + const rawSummary = typeof raw === "string" ? raw : typeof raw; + log.warn(`ignoring invalid OpenAI text verbosity param: ${rawSummary}`); + } + return normalized; +} diff --git a/src/agents/openai-tool-schema.ts b/src/agents/openai-tool-schema.ts index 24d536b6b3c..4c05886b4ea 100644 --- a/src/agents/openai-tool-schema.ts +++ b/src/agents/openai-tool-schema.ts @@ -1,23 +1,13 @@ -import { readStringValue } from "../shared/string-coerce.js"; -import { normalizeToolParameterSchema } from "./pi-tools.schema.js"; -import { resolveProviderRequestCapabilities } from "./provider-attribution.js"; - -type OpenAITransportKind = "stream" | "websocket"; - -type OpenAIStrictToolModel = { - provider?: unknown; - api?: unknown; - baseUrl?: unknown; - id?: unknown; - compat?: { supportsStore?: boolean }; -}; +import { normalizeToolParameterSchema } from "./pi-tools-parameter-schema.js"; +export { + resolveOpenAIStrictToolSetting, + resolvesToNativeOpenAIStrictTools, +} from "./openai-strict-tool-setting.js"; type ToolWithParameters = { parameters: unknown; }; -const optionalString = readStringValue; - export function normalizeStrictOpenAIJsonSchema(schema: unknown): unknown { return normalizeStrictOpenAIJsonSchemaRecursive(normalizeToolParameterSchema(schema ?? {})); } @@ -128,43 +118,3 @@ export function resolveOpenAIStrictToolFlagForInventory( } return tools.every((tool) => isStrictOpenAIJsonSchemaCompatible(tool.parameters)); } - -export function resolvesToNativeOpenAIStrictTools( - model: OpenAIStrictToolModel, - transport: OpenAITransportKind, -): boolean { - const capabilities = resolveProviderRequestCapabilities({ - provider: optionalString(model.provider), - api: optionalString(model.api), - baseUrl: optionalString(model.baseUrl), - capability: "llm", - transport, - modelId: optionalString(model.id), - compat: - model.compat && typeof model.compat === "object" - ? (model.compat as { supportsStore?: boolean }) - : undefined, - }); - if (!capabilities.usesKnownNativeOpenAIRoute) { - return false; - } - return ( - capabilities.provider === "openai" || - capabilities.provider === "openai-codex" || - capabilities.provider === "azure-openai" || - capabilities.provider === "azure-openai-responses" - ); -} - -export function resolveOpenAIStrictToolSetting( - model: OpenAIStrictToolModel, - options?: { transport?: OpenAITransportKind; supportsStrictMode?: boolean }, -): boolean | undefined { - if (resolvesToNativeOpenAIStrictTools(model, options?.transport ?? "stream")) { - return true; - } - if (options?.supportsStrictMode) { - return false; - } - return undefined; -} diff --git a/src/agents/openai-ws-request.ts b/src/agents/openai-ws-request.ts index 0792e70e5f9..a9900dd81fc 100644 --- a/src/agents/openai-ws-request.ts +++ b/src/agents/openai-ws-request.ts @@ -2,13 +2,13 @@ import type { StreamFn } from "@mariozechner/pi-agent-core"; import { readStringValue } from "../shared/string-coerce.js"; import { mapOpenAIReasoningEffortForModel } from "./openai-reasoning-compat.js"; import { normalizeOpenAIReasoningEffort } from "./openai-reasoning-effort.js"; +import { resolveOpenAITextVerbosity } from "./openai-text-verbosity.js"; import type { FunctionToolDefinition, InputItem, ResponseCreateEvent, WarmUpEvent, } from "./openai-ws-types.js"; -import { resolveOpenAITextVerbosity } from "./pi-embedded-runner/openai-stream-wrappers.js"; import { resolveProviderRequestPolicyConfig } from "./provider-request-config.js"; import { stripSystemPromptCacheBoundary } from "./system-prompt-cache-boundary.js"; diff --git a/src/agents/openai-ws-stream.test.ts b/src/agents/openai-ws-stream.test.ts index df50ae32f28..959b52e197a 100644 --- a/src/agents/openai-ws-stream.test.ts +++ b/src/agents/openai-ws-stream.test.ts @@ -213,6 +213,11 @@ const { MockManager } = vi.hoisted(() => { return { MockManager: TrackedMockManager }; }); +vi.mock("../plugins/provider-runtime.js", () => ({ + resolveProviderTransportTurnStateWithPlugin: () => undefined, + resolveProviderWebSocketSessionPolicyWithPlugin: () => undefined, +})); + // Track if streamSimple (HTTP fallback) was called const streamSimpleCalls: Array<{ model: unknown; context: unknown; options?: unknown }> = []; const mockStreamSimple = vi.fn((model: unknown, context: unknown, options?: unknown) => { @@ -2526,6 +2531,7 @@ describe("createOpenAIWebSocketStreamFn", () => { it("keeps websocket degraded for the session until the cool-down expires", async () => { openAIWsStreamTesting.setWsDegradeCooldownMsForTest(50); MockManager.globalConnectShouldFail = true; + const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1_000); try { const sessionId = "sess-degraded-cooldown"; @@ -2560,7 +2566,7 @@ describe("createOpenAIWebSocketStreamFn", () => { expect(MockManager.instances).toHaveLength(2); expect(cooledManager.connectCallCount).toBe(0); - await new Promise((resolve) => setTimeout(resolve, 60)); + nowSpy.mockReturnValue(1_060); const thirdStream = streamFn( modelStub as Parameters[0], @@ -2579,6 +2585,7 @@ describe("createOpenAIWebSocketStreamFn", () => { }); await new Promise((resolve) => setImmediate(resolve)); } finally { + nowSpy.mockRestore(); MockManager.globalConnectShouldFail = false; openAIWsStreamTesting.setWsDegradeCooldownMsForTest(); releaseWsSession("sess-degraded-cooldown"); diff --git a/src/agents/openai-ws-stream.ts b/src/agents/openai-ws-stream.ts index 5de1bb76f79..bc98120c81c 100644 --- a/src/agents/openai-ws-stream.ts +++ b/src/agents/openai-ws-stream.ts @@ -41,7 +41,7 @@ import { normalizeAssistantPhase, } from "../shared/chat-message-content.js"; import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; -import { resolveOpenAIStrictToolSetting } from "./openai-tool-schema.js"; +import { resolveOpenAIStrictToolSetting } from "./openai-strict-tool-setting.js"; import { getOpenAIWebSocketErrorDetails, OpenAIWebSocketManager, diff --git a/src/agents/pi-embedded-runner-extraparams.test.ts b/src/agents/pi-embedded-runner-extraparams.test.ts index dbdd2982fee..94703dc7c0e 100644 --- a/src/agents/pi-embedded-runner-extraparams.test.ts +++ b/src/agents/pi-embedded-runner-extraparams.test.ts @@ -3,6 +3,89 @@ import type { Context, Model, SimpleStreamOptions } from "@mariozechner/pi-ai"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { __testing as extraParamsTesting } from "./pi-embedded-runner/extra-params.js"; +vi.mock("../plugins/provider-hook-runtime.js", () => ({ + __testing: { + buildHookProviderCacheKey: () => "test-provider-hook-cache-key", + }, + prepareProviderExtraParams: () => undefined, + resetProviderRuntimeHookCacheForTest: () => {}, + wrapProviderStreamFn: (params: { context: { streamFn?: StreamFn } }) => params.context.streamFn, +})); + +vi.mock("./codex-native-web-search.js", () => ({ + patchCodexNativeWebSearchPayload: (params: { + payload: unknown; + config?: { + tools?: { + web?: { + search?: { + openaiCodex?: { + mode?: string; + allowedDomains?: string[]; + }; + }; + }; + }; + }; + }) => { + if (!params.payload || typeof params.payload !== "object") { + return { status: "payload_not_object" }; + } + const payload = params.payload as { tools?: Array> }; + if (payload.tools?.some((tool) => tool.type === "web_search")) { + return { status: "native_tool_already_present" }; + } + const nativeConfig = params.config?.tools?.web?.search?.openaiCodex; + payload.tools = [ + ...(payload.tools ?? []), + { + type: "web_search", + external_web_access: nativeConfig?.mode === "live", + ...(nativeConfig?.allowedDomains + ? { filters: { allowed_domains: nativeConfig.allowedDomains } } + : {}), + }, + ]; + return { status: "injected" }; + }, + resolveCodexNativeSearchActivation: (params: { + config?: { + auth?: { profiles?: Record }; + tools?: { + web?: { + search?: { + enabled?: boolean; + openaiCodex?: { enabled?: boolean; mode?: string }; + }; + }; + }; + }; + modelProvider?: string; + modelApi?: string; + }) => { + const search = params.config?.tools?.web?.search; + const codex = search?.openaiCodex; + const nativeEligible = + params.modelProvider === "openai-codex" || params.modelApi === "openai-codex-responses"; + const hasRequiredAuth = + params.modelProvider !== "openai-codex" || + Object.values(params.config?.auth?.profiles ?? {}).some( + (profile) => profile.provider === "openai-codex", + ); + const active = + search?.enabled !== false && codex?.enabled === true && nativeEligible && hasRequiredAuth; + return { + globalWebSearchEnabled: search?.enabled !== false, + codexNativeEnabled: codex?.enabled === true, + codexMode: codex?.mode === "live" ? "live" : "cached", + nativeEligible, + hasRequiredAuth, + state: active ? "native_active" : "managed_only", + ...(active ? {} : { inactiveReason: "test_inactive" }), + }; + }, +})); + const ANTHROPIC_DEFAULT_BETAS = [ "fine-grained-tool-streaming-2025-05-14", "interleaved-thinking-2025-05-14", diff --git a/src/agents/pi-embedded-runner/openai-stream-wrappers.ts b/src/agents/pi-embedded-runner/openai-stream-wrappers.ts index 6971bf21b19..c8e83e1a1cc 100644 --- a/src/agents/pi-embedded-runner/openai-stream-wrappers.ts +++ b/src/agents/pi-embedded-runner/openai-stream-wrappers.ts @@ -12,12 +12,13 @@ import { applyOpenAIResponsesPayloadPolicy, resolveOpenAIResponsesPayloadPolicy, } from "../openai-responses-payload-policy.js"; +import { resolveOpenAITextVerbosity, type OpenAITextVerbosity } from "../openai-text-verbosity.js"; import { resolveProviderRequestPolicyConfig } from "../provider-request-config.js"; import { log } from "./logger.js"; import { streamWithPayloadPatch } from "./stream-payload-utils.js"; type OpenAIServiceTier = "auto" | "default" | "flex" | "priority"; -type OpenAITextVerbosity = "low" | "medium" | "high"; +export { resolveOpenAITextVerbosity }; function resolveOpenAIRequestCapabilities(model: { api?: unknown; @@ -106,29 +107,6 @@ export function resolveOpenAIServiceTier( return normalized; } -function normalizeOpenAITextVerbosity(value: unknown): OpenAITextVerbosity | undefined { - if (typeof value !== "string") { - return undefined; - } - const normalized = normalizeOptionalLowercaseString(value); - if (normalized === "low" || normalized === "medium" || normalized === "high") { - return normalized; - } - return undefined; -} - -export function resolveOpenAITextVerbosity( - extraParams: Record | undefined, -): OpenAITextVerbosity | undefined { - const raw = extraParams?.textVerbosity ?? extraParams?.text_verbosity; - const normalized = normalizeOpenAITextVerbosity(raw); - if (raw !== undefined && normalized === undefined) { - const rawSummary = typeof raw === "string" ? raw : typeof raw; - log.warn(`ignoring invalid OpenAI text verbosity param: ${rawSummary}`); - } - return normalized; -} - function normalizeOpenAIFastMode(value: unknown): boolean | undefined { if (typeof value === "boolean") { return value; diff --git a/src/agents/pi-tools-parameter-schema.ts b/src/agents/pi-tools-parameter-schema.ts new file mode 100644 index 00000000000..b1da8ea2f3b --- /dev/null +++ b/src/agents/pi-tools-parameter-schema.ts @@ -0,0 +1,257 @@ +import type { ModelCompatConfig } from "../config/types.models.js"; +import { stripUnsupportedSchemaKeywords } from "../plugin-sdk/provider-tools.js"; +import { resolveUnsupportedToolSchemaKeywords } from "../plugins/provider-model-compat.js"; +import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; +import { cleanSchemaForGemini } from "./schema/clean-for-gemini.js"; + +export type ToolParameterSchemaOptions = { + modelProvider?: string; + modelId?: string; + modelCompat?: ModelCompatConfig; +}; + +function extractEnumValues(schema: unknown): unknown[] | undefined { + if (!schema || typeof schema !== "object") { + return undefined; + } + const record = schema as Record; + if (Array.isArray(record.enum)) { + return record.enum; + } + if ("const" in record) { + return [record.const]; + } + const variants = Array.isArray(record.anyOf) + ? record.anyOf + : Array.isArray(record.oneOf) + ? record.oneOf + : null; + if (variants) { + const values = variants.flatMap((variant) => { + const extracted = extractEnumValues(variant); + return extracted ?? []; + }); + return values.length > 0 ? values : undefined; + } + return undefined; +} + +function mergePropertySchemas(existing: unknown, incoming: unknown): unknown { + if (!existing) { + return incoming; + } + if (!incoming) { + return existing; + } + + const existingEnum = extractEnumValues(existing); + const incomingEnum = extractEnumValues(incoming); + if (existingEnum || incomingEnum) { + const values = Array.from(new Set([...(existingEnum ?? []), ...(incomingEnum ?? [])])); + const merged: Record = {}; + for (const source of [existing, incoming]) { + if (!source || typeof source !== "object") { + continue; + } + const record = source as Record; + for (const key of ["title", "description", "default"]) { + if (!(key in merged) && key in record) { + merged[key] = record[key]; + } + } + } + const types = new Set(values.map((value) => typeof value)); + if (types.size === 1) { + merged.type = Array.from(types)[0]; + } + merged.enum = values; + return merged; + } + + return existing; +} + +type FlattenableVariantKey = "anyOf" | "oneOf"; +type TopLevelConditionalKey = FlattenableVariantKey | "allOf"; + +function hasTopLevelArrayKeyword( + schemaRecord: Record, + key: TopLevelConditionalKey, +): boolean { + return Array.isArray(schemaRecord[key]); +} + +function getFlattenableVariantKey( + schemaRecord: Record, +): FlattenableVariantKey | null { + if (hasTopLevelArrayKeyword(schemaRecord, "anyOf")) { + return "anyOf"; + } + if (hasTopLevelArrayKeyword(schemaRecord, "oneOf")) { + return "oneOf"; + } + return null; +} + +function getTopLevelConditionalKey( + schemaRecord: Record, +): TopLevelConditionalKey | null { + return ( + getFlattenableVariantKey(schemaRecord) ?? + (hasTopLevelArrayKeyword(schemaRecord, "allOf") ? "allOf" : null) + ); +} + +function hasTopLevelObjectSchema( + schemaRecord: Record, + conditionalKey: TopLevelConditionalKey | null, +): boolean { + return "type" in schemaRecord && "properties" in schemaRecord && conditionalKey === null; +} + +function isObjectLikeSchemaMissingType( + schemaRecord: Record, + conditionalKey: TopLevelConditionalKey | null, +): boolean { + return ( + !("type" in schemaRecord) && + (typeof schemaRecord.properties === "object" || Array.isArray(schemaRecord.required)) && + conditionalKey === null + ); +} + +function isTypedSchemaMissingProperties( + schemaRecord: Record, + conditionalKey: TopLevelConditionalKey | null, +): boolean { + return "type" in schemaRecord && !("properties" in schemaRecord) && conditionalKey === null; +} + +function isTrulyEmptySchema(schemaRecord: Record): boolean { + return Object.keys(schemaRecord).length === 0; +} + +export function normalizeToolParameterSchema( + schema: unknown, + options?: { modelProvider?: string; modelId?: string; modelCompat?: ModelCompatConfig }, +): unknown { + const schemaRecord = + schema && typeof schema === "object" ? (schema as Record) : undefined; + if (!schemaRecord) { + return schema; + } + + // Provider quirks: + // - Gemini rejects several JSON Schema keywords, so we scrub those. + // - OpenAI rejects function tool schemas unless the *top-level* is `type: "object"`. + // (TypeBox root unions compile to `{ anyOf: [...] }` without `type`). + // - Anthropic expects full JSON Schema draft 2020-12 compliance. + // - xAI rejects validation-constraint keywords (minLength, maxLength, etc.) outright. + // + // Normalize once here so callers can always pass `tools` through unchanged. + const normalizedProvider = normalizeLowercaseStringOrEmpty(options?.modelProvider); + const isGeminiProvider = + normalizedProvider.includes("google") || normalizedProvider.includes("gemini"); + const isAnthropicProvider = normalizedProvider.includes("anthropic"); + const unsupportedToolSchemaKeywords = resolveUnsupportedToolSchemaKeywords(options?.modelCompat); + + function applyProviderCleaning(s: unknown): unknown { + if (isGeminiProvider && !isAnthropicProvider) { + return cleanSchemaForGemini(s); + } + if (unsupportedToolSchemaKeywords.size > 0) { + return stripUnsupportedSchemaKeywords(s, unsupportedToolSchemaKeywords); + } + return s; + } + + const conditionalKey = getTopLevelConditionalKey(schemaRecord); + const flattenableVariantKey = getFlattenableVariantKey(schemaRecord); + + if (hasTopLevelObjectSchema(schemaRecord, conditionalKey)) { + return applyProviderCleaning(schemaRecord); + } + + if (isObjectLikeSchemaMissingType(schemaRecord, conditionalKey)) { + return applyProviderCleaning({ ...schemaRecord, type: "object" }); + } + + if (isTypedSchemaMissingProperties(schemaRecord, conditionalKey)) { + return applyProviderCleaning({ ...schemaRecord, properties: {} }); + } + + if (!flattenableVariantKey) { + if (isTrulyEmptySchema(schemaRecord)) { + // Handle the proven MCP no-parameter case: a truly empty schema object. + return applyProviderCleaning({ type: "object", properties: {} }); + } + if (conditionalKey === "allOf") { + // Top-level `allOf` is not safely flattenable with the same heuristics we + // use for unions. Keep it explicit rather than silently rewriting it. + return schema; + } + return schema; + } + const variants = schemaRecord[flattenableVariantKey] as unknown[]; + const mergedProperties: Record = {}; + const requiredCounts = new Map(); + let objectVariants = 0; + + for (const entry of variants) { + if (!entry || typeof entry !== "object") { + continue; + } + const props = (entry as { properties?: unknown }).properties; + if (!props || typeof props !== "object") { + continue; + } + objectVariants += 1; + for (const [key, value] of Object.entries(props as Record)) { + if (!(key in mergedProperties)) { + mergedProperties[key] = value; + continue; + } + mergedProperties[key] = mergePropertySchemas(mergedProperties[key], value); + } + const required = Array.isArray((entry as { required?: unknown }).required) + ? (entry as { required: unknown[] }).required + : []; + for (const key of required) { + if (typeof key !== "string") { + continue; + } + requiredCounts.set(key, (requiredCounts.get(key) ?? 0) + 1); + } + } + + const baseRequired = Array.isArray(schemaRecord.required) + ? schemaRecord.required.filter((key) => typeof key === "string") + : undefined; + const mergedRequired = + baseRequired && baseRequired.length > 0 + ? baseRequired + : objectVariants > 0 + ? Array.from(requiredCounts.entries()) + .filter(([, count]) => count === objectVariants) + .map(([key]) => key) + : undefined; + + const nextSchema: Record = { ...schemaRecord }; + const flattenedSchema = { + type: "object", + ...(typeof nextSchema.title === "string" ? { title: nextSchema.title } : {}), + ...(typeof nextSchema.description === "string" ? { description: nextSchema.description } : {}), + properties: + Object.keys(mergedProperties).length > 0 ? mergedProperties : (schemaRecord.properties ?? {}), + ...(mergedRequired && mergedRequired.length > 0 ? { required: mergedRequired } : {}), + additionalProperties: + "additionalProperties" in schemaRecord ? schemaRecord.additionalProperties : true, + }; + + // Flatten union schemas into a single object schema: + // - Gemini doesn't allow top-level `type` together with `anyOf`. + // - OpenAI rejects schemas without top-level `type: "object"`. + // - Anthropic accepts proper JSON Schema with constraints. + // Merging properties preserves useful enums like `action` while keeping schemas portable. + return applyProviderCleaning(flattenedSchema); +} diff --git a/src/agents/pi-tools.schema.ts b/src/agents/pi-tools.schema.ts index 7cde86fd837..bfac93f39f7 100644 --- a/src/agents/pi-tools.schema.ts +++ b/src/agents/pi-tools.schema.ts @@ -1,261 +1,16 @@ -import type { ModelCompatConfig } from "../config/types.models.js"; -import { stripUnsupportedSchemaKeywords } from "../plugin-sdk/provider-tools.js"; -import { resolveUnsupportedToolSchemaKeywords } from "../plugins/provider-model-compat.js"; import { copyPluginToolMeta } from "../plugins/tools.js"; -import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; import { copyChannelAgentToolMeta } from "./channel-tools.js"; +import { + normalizeToolParameterSchema, + type ToolParameterSchemaOptions, +} from "./pi-tools-parameter-schema.js"; import type { AnyAgentTool } from "./pi-tools.types.js"; -import { cleanSchemaForGemini } from "./schema/clean-for-gemini.js"; -function extractEnumValues(schema: unknown): unknown[] | undefined { - if (!schema || typeof schema !== "object") { - return undefined; - } - const record = schema as Record; - if (Array.isArray(record.enum)) { - return record.enum; - } - if ("const" in record) { - return [record.const]; - } - const variants = Array.isArray(record.anyOf) - ? record.anyOf - : Array.isArray(record.oneOf) - ? record.oneOf - : null; - if (variants) { - const values = variants.flatMap((variant) => { - const extracted = extractEnumValues(variant); - return extracted ?? []; - }); - return values.length > 0 ? values : undefined; - } - return undefined; -} - -function mergePropertySchemas(existing: unknown, incoming: unknown): unknown { - if (!existing) { - return incoming; - } - if (!incoming) { - return existing; - } - - const existingEnum = extractEnumValues(existing); - const incomingEnum = extractEnumValues(incoming); - if (existingEnum || incomingEnum) { - const values = Array.from(new Set([...(existingEnum ?? []), ...(incomingEnum ?? [])])); - const merged: Record = {}; - for (const source of [existing, incoming]) { - if (!source || typeof source !== "object") { - continue; - } - const record = source as Record; - for (const key of ["title", "description", "default"]) { - if (!(key in merged) && key in record) { - merged[key] = record[key]; - } - } - } - const types = new Set(values.map((value) => typeof value)); - if (types.size === 1) { - merged.type = Array.from(types)[0]; - } - merged.enum = values; - return merged; - } - - return existing; -} - -type FlattenableVariantKey = "anyOf" | "oneOf"; -type TopLevelConditionalKey = FlattenableVariantKey | "allOf"; - -function hasTopLevelArrayKeyword( - schemaRecord: Record, - key: TopLevelConditionalKey, -): boolean { - return Array.isArray(schemaRecord[key]); -} - -function getFlattenableVariantKey( - schemaRecord: Record, -): FlattenableVariantKey | null { - if (hasTopLevelArrayKeyword(schemaRecord, "anyOf")) { - return "anyOf"; - } - if (hasTopLevelArrayKeyword(schemaRecord, "oneOf")) { - return "oneOf"; - } - return null; -} - -function getTopLevelConditionalKey( - schemaRecord: Record, -): TopLevelConditionalKey | null { - return ( - getFlattenableVariantKey(schemaRecord) ?? - (hasTopLevelArrayKeyword(schemaRecord, "allOf") ? "allOf" : null) - ); -} - -function hasTopLevelObjectSchema( - schemaRecord: Record, - conditionalKey: TopLevelConditionalKey | null, -): boolean { - return "type" in schemaRecord && "properties" in schemaRecord && conditionalKey === null; -} - -function isObjectLikeSchemaMissingType( - schemaRecord: Record, - conditionalKey: TopLevelConditionalKey | null, -): boolean { - return ( - !("type" in schemaRecord) && - (typeof schemaRecord.properties === "object" || Array.isArray(schemaRecord.required)) && - conditionalKey === null - ); -} - -function isTypedSchemaMissingProperties( - schemaRecord: Record, - conditionalKey: TopLevelConditionalKey | null, -): boolean { - return "type" in schemaRecord && !("properties" in schemaRecord) && conditionalKey === null; -} - -function isTrulyEmptySchema(schemaRecord: Record): boolean { - return Object.keys(schemaRecord).length === 0; -} - -export function normalizeToolParameterSchema( - schema: unknown, - options?: { modelProvider?: string; modelId?: string; modelCompat?: ModelCompatConfig }, -): unknown { - const schemaRecord = - schema && typeof schema === "object" ? (schema as Record) : undefined; - if (!schemaRecord) { - return schema; - } - - // Provider quirks: - // - Gemini rejects several JSON Schema keywords, so we scrub those. - // - OpenAI rejects function tool schemas unless the *top-level* is `type: "object"`. - // (TypeBox root unions compile to `{ anyOf: [...] }` without `type`). - // - Anthropic expects full JSON Schema draft 2020-12 compliance. - // - xAI rejects validation-constraint keywords (minLength, maxLength, etc.) outright. - // - // Normalize once here so callers can always pass `tools` through unchanged. - const normalizedProvider = normalizeLowercaseStringOrEmpty(options?.modelProvider); - const isGeminiProvider = - normalizedProvider.includes("google") || normalizedProvider.includes("gemini"); - const isAnthropicProvider = normalizedProvider.includes("anthropic"); - const unsupportedToolSchemaKeywords = resolveUnsupportedToolSchemaKeywords(options?.modelCompat); - - function applyProviderCleaning(s: unknown): unknown { - if (isGeminiProvider && !isAnthropicProvider) { - return cleanSchemaForGemini(s); - } - if (unsupportedToolSchemaKeywords.size > 0) { - return stripUnsupportedSchemaKeywords(s, unsupportedToolSchemaKeywords); - } - return s; - } - - const conditionalKey = getTopLevelConditionalKey(schemaRecord); - const flattenableVariantKey = getFlattenableVariantKey(schemaRecord); - - if (hasTopLevelObjectSchema(schemaRecord, conditionalKey)) { - return applyProviderCleaning(schemaRecord); - } - - if (isObjectLikeSchemaMissingType(schemaRecord, conditionalKey)) { - return applyProviderCleaning({ ...schemaRecord, type: "object" }); - } - - if (isTypedSchemaMissingProperties(schemaRecord, conditionalKey)) { - return applyProviderCleaning({ ...schemaRecord, properties: {} }); - } - - if (!flattenableVariantKey) { - if (isTrulyEmptySchema(schemaRecord)) { - // Handle the proven MCP no-parameter case: a truly empty schema object. - return applyProviderCleaning({ type: "object", properties: {} }); - } - if (conditionalKey === "allOf") { - // Top-level `allOf` is not safely flattenable with the same heuristics we - // use for unions. Keep it explicit rather than silently rewriting it. - return schema; - } - return schema; - } - const variants = schemaRecord[flattenableVariantKey] as unknown[]; - const mergedProperties: Record = {}; - const requiredCounts = new Map(); - let objectVariants = 0; - - for (const entry of variants) { - if (!entry || typeof entry !== "object") { - continue; - } - const props = (entry as { properties?: unknown }).properties; - if (!props || typeof props !== "object") { - continue; - } - objectVariants += 1; - for (const [key, value] of Object.entries(props as Record)) { - if (!(key in mergedProperties)) { - mergedProperties[key] = value; - continue; - } - mergedProperties[key] = mergePropertySchemas(mergedProperties[key], value); - } - const required = Array.isArray((entry as { required?: unknown }).required) - ? (entry as { required: unknown[] }).required - : []; - for (const key of required) { - if (typeof key !== "string") { - continue; - } - requiredCounts.set(key, (requiredCounts.get(key) ?? 0) + 1); - } - } - - const baseRequired = Array.isArray(schemaRecord.required) - ? schemaRecord.required.filter((key) => typeof key === "string") - : undefined; - const mergedRequired = - baseRequired && baseRequired.length > 0 - ? baseRequired - : objectVariants > 0 - ? Array.from(requiredCounts.entries()) - .filter(([, count]) => count === objectVariants) - .map(([key]) => key) - : undefined; - - const nextSchema: Record = { ...schemaRecord }; - const flattenedSchema = { - type: "object", - ...(typeof nextSchema.title === "string" ? { title: nextSchema.title } : {}), - ...(typeof nextSchema.description === "string" ? { description: nextSchema.description } : {}), - properties: - Object.keys(mergedProperties).length > 0 ? mergedProperties : (schemaRecord.properties ?? {}), - ...(mergedRequired && mergedRequired.length > 0 ? { required: mergedRequired } : {}), - additionalProperties: - "additionalProperties" in schemaRecord ? schemaRecord.additionalProperties : true, - }; - - // Flatten union schemas into a single object schema: - // - Gemini doesn't allow top-level `type` together with `anyOf`. - // - OpenAI rejects schemas without top-level `type: "object"`. - // - Anthropic accepts proper JSON Schema with constraints. - // Merging properties preserves useful enums like `action` while keeping schemas portable. - return applyProviderCleaning(flattenedSchema); -} +export { normalizeToolParameterSchema }; export function normalizeToolParameters( tool: AnyAgentTool, - options?: { modelProvider?: string; modelId?: string; modelCompat?: ModelCompatConfig }, + options?: ToolParameterSchemaOptions, ): AnyAgentTool { function preserveToolMeta(target: AnyAgentTool): AnyAgentTool { copyPluginToolMeta(tool, target); @@ -280,5 +35,5 @@ export function normalizeToolParameters( * This function should only be used for Gemini providers. */ export function cleanToolSchemaForGemini(schema: Record): unknown { - return cleanSchemaForGemini(schema); + return normalizeToolParameterSchema(schema, { modelProvider: "gemini" }); } diff --git a/src/auto-reply/status.ts b/src/auto-reply/status.ts index c73732a94f0..9d09f0e7995 100644 --- a/src/auto-reply/status.ts +++ b/src/auto-reply/status.ts @@ -7,8 +7,8 @@ import { resolveConfiguredModelRef, resolveModelRefFromString, } from "../agents/model-selection.js"; +import { resolveOpenAITextVerbosity } from "../agents/openai-text-verbosity.js"; import { resolveExtraParams } from "../agents/pi-embedded-runner/extra-params.js"; -import { resolveOpenAITextVerbosity } from "../agents/pi-embedded-runner/openai-stream-wrappers.js"; import { resolveSandboxRuntimeStatus } from "../agents/sandbox.js"; import { describeToolForVerbose } from "../agents/tool-description-summary.js"; import { normalizeToolName } from "../agents/tool-policy-shared.js";