From e327668bc221915f790326d3165f0cd9a1e55225 Mon Sep 17 00:00:00 2001 From: Matthew Evanusa Date: Thu, 23 Jul 2026 16:58:14 -0700 Subject: [PATCH] fix(translate): bail to non-strict for bare-{} optional tool args MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #829 fixed the Workflow.args 400 by having makeNullable synthesize a six-type union branch for a typeless optional, keeping the tool strict. But that branch admits "object", and OpenAI strict mode then requires the node to carry additionalProperties:false — which the synthetic branch lacks — so the request still 400'd, now at context=('properties','args','anyOf','0','type','3'). A bare {} "any JSON value" has no strict representation: closing it with additionalProperties:false would forbid the arbitrary keys the parameter exists to accept, and narrowing the type union would silently drop valid object/array args. So strictifyNode now bails when an optional property has no strict-expressible type, and the whole tool falls back to non-strict emission per this file's documented fail-open contract. Reverts makeNullable's typeless fallback to wrapping the node, now unreachable behind the schemaHasStrictType guard. Co-Authored-By: Claude Fable 5 --- internal/translate/strictify_openai.go | 23 ++++++++++++++------- internal/translate/strictify_openai_test.go | 21 +++++++------------ 2 files changed, 24 insertions(+), 20 deletions(-) diff --git a/internal/translate/strictify_openai.go b/internal/translate/strictify_openai.go index 7ed19601..bef326b5 100644 --- a/internal/translate/strictify_openai.go +++ b/internal/translate/strictify_openai.go @@ -168,6 +168,17 @@ func strictifyNode(node map[string]any, depth int, propCount *int) (out map[stri return nil, false } if _, req := originallyRequired[name]; !req { + // An optional property with no strict-expressible type (a bare {} + // "any JSON value", e.g. Workflow.args) cannot be made nullable + // without either admitting "object" — which strict mode then + // requires to carry additionalProperties:false, forbidding the + // arbitrary keys the schema exists to accept — or narrowing the + // value space and silently dropping valid object/array args. + // There is no strict representation of an open value, so bail to + // non-strict emission per this file's fail-open contract. + if !schemaHasStrictType(sp) { + return nil, false + } sp = makeNullable(sp) } outProps[name] = sp @@ -206,13 +217,11 @@ func makeNullable(node map[string]any) map[string]any { node["anyOf"] = append(branches, map[string]any{"type": "null"}) return node } - // No type and no anyOf: give the synthetic branch an explicit value-type union - // so OpenAI strict mode accepts it; preserve any inline constraints on the original node. - branch := map[string]any{"type": []any{"string", "number", "boolean", "object", "array", "null"}} - for k, v := range node { - branch[k] = v - } - return map[string]any{"anyOf": []any{branch, map[string]any{"type": "null"}}} + // No type and no anyOf (e.g. bare enum): wrap the node itself. Callers must + // not pass a fully typeless node here — strictifyNode bails on typeless + // optionals before reaching this point, since an open value has no strict + // representation (see the schemaHasStrictType guard in strictifyNode). + return map[string]any{"anyOf": []any{node, map[string]any{"type": "null"}}} } // schemaHasStrictType reports whether node carries a type OpenAI strict mode diff --git a/internal/translate/strictify_openai_test.go b/internal/translate/strictify_openai_test.go index 0ffd0551..99194230 100644 --- a/internal/translate/strictify_openai_test.go +++ b/internal/translate/strictify_openai_test.go @@ -236,21 +236,16 @@ func TestStrictify_UnevaluatedPropertiesBails(t *testing.T) { require.False(t, ok, "unevaluatedProperties is not expressible in strict mode; must bail") } -// Workflow.args is bare {} — makeNullable's synthetic anyOf branch had no 'type' key -// and OpenAI strict mode 400'd; verify the fix adds an explicit value-type union. -func TestStrictify_TypelessOptionalGetsExplicitValueType(t *testing.T) { - out, ok := strictifyFromJSON(t, `{ +// Workflow.args is a bare {} "any JSON value" optional. #829 kept it strict by +// synthesizing a 6-type union branch, but that branch admits "object" without +// additionalProperties:false, so OpenAI strict mode still 400'd +// (context=('properties','args','anyOf','0','type','3')). An open value has no +// strict representation, so the whole tool must bail to non-strict emission. +func TestStrictify_TypelessOptionalBails(t *testing.T) { + _, ok := strictifyFromJSON(t, `{ "type":"object", "properties":{"args":{}}, "required":[] }`) - require.True(t, ok, "a typeless optional property must survive strictification, not bail") - - args := out["properties"].(map[string]any)["args"].(map[string]any) - branches, has := args["anyOf"].([]any) - require.True(t, has, "a typeless optional is made nullable via anyOf") - require.Len(t, branches, 2) - assert.Equal(t, []any{"string", "number", "boolean", "object", "array", "null"}, branches[0].(map[string]any)["type"], - "a typeless branch must carry an explicit value-type union so OpenAI strict mode doesn't 400") - assert.Equal(t, map[string]any{"type": "null"}, branches[1]) + require.False(t, ok, "a bare {} optional (any JSON value) has no strict form; must fall back to non-strict") }