From b6ce0beecd31dff389d3190f7db6d7a1d4ce0e7e Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Thu, 16 Jul 2026 02:05:50 +0800 Subject: [PATCH] feat(executor): simplify xAI function schema handling and rejection logic - Refactored `xaiFunctionParametersNeedSimplification` to identify schemas xAI cannot process, including those with non-object types in unions. - Limited parameter simplifications to problematic schemas instead of specific tools or namespaces. - Clarified and updated debug messages to reflect schema rejection and hang prevention. Closes: #4343 --- internal/runtime/executor/xai_executor.go | 60 +++++++++++++--- .../runtime/executor/xai_executor_test.go | 69 ++++++++++++++++++- 2 files changed, 119 insertions(+), 10 deletions(-) diff --git a/internal/runtime/executor/xai_executor.go b/internal/runtime/executor/xai_executor.go index bff83c4d..760d0123 100644 --- a/internal/runtime/executor/xai_executor.go +++ b/internal/runtime/executor/xai_executor.go @@ -1579,9 +1579,8 @@ func normalizeXAITool(tool gjson.Result, namespaceName string) ([]byte, bool, bo raw = updatedTool changed = true } - // Codex Desktop's codex_app.automation_update schema hangs xAI free/build - // streaming. Limit the workaround to that exact namespaced tool so unrelated - // tools keep their parameter contracts. + // Simplify the Codex Desktop automation schema and root unions that xAI + // rejects because function parameters must resolve exclusively to objects. if toolType == xaiFunctionToolType && xaiFunctionParametersNeedSimplification(tool, namespaceName) { updatedTool, errSet := sjson.SetRawBytes(raw, "parameters", []byte(xaiSafeFunctionParameters)) if errSet != nil { @@ -1596,7 +1595,7 @@ func normalizeXAITool(tool gjson.Result, namespaceName string) ([]byte, bool, bo raw = updatedTool } changed = true - log.Debugf("xai: simplified parameters for tool %s.%s to avoid upstream hang", namespaceName, tool.Get("name").String()) + log.Debugf("xai: simplified parameters for tool %s.%s to avoid upstream schema rejection or hang", namespaceName, tool.Get("name").String()) } if toolType == xaiFunctionToolType && strings.TrimSpace(namespaceName) != "" { qualifiedName := qualifyXAINamespaceToolName(namespaceName, tool.Get("name").String()) @@ -2086,12 +2085,55 @@ func restoreXAINamespaceToolCallAtPath(data []byte, path string, refs map[string return updated } -// xaiFunctionParametersNeedSimplification reports whether a function tool is -// the Codex Desktop automation tool known to hang xAI Responses streaming. +// xaiFunctionParametersNeedSimplification reports whether a function tool, or +// a custom tool normalized to a function, has a schema that xAI cannot accept. func xaiFunctionParametersNeedSimplification(tool gjson.Result, namespaceName string) bool { - return strings.EqualFold(strings.TrimSpace(tool.Get("type").String()), xaiFunctionToolType) && - strings.EqualFold(strings.TrimSpace(namespaceName), xaiCodexAppNamespaceName) && - strings.EqualFold(strings.TrimSpace(tool.Get("name").String()), xaiAutomationUpdateToolName) + toolType := strings.TrimSpace(tool.Get("type").String()) + isFunction := strings.EqualFold(toolType, xaiFunctionToolType) + isNormalizedCustom := strings.EqualFold(toolType, xaiCustomToolType) + if !isFunction && !isNormalizedCustom { + return false + } + + toolName := strings.TrimSpace(tool.Get("name").String()) + qualifiedAutomationName := xaiCodexAppNamespaceName + "__" + xaiAutomationUpdateToolName + if isFunction && (strings.EqualFold(toolName, qualifiedAutomationName) || + (strings.EqualFold(strings.TrimSpace(namespaceName), xaiCodexAppNamespaceName) && + strings.EqualFold(toolName, xaiAutomationUpdateToolName))) { + return true + } + + parameters := tool.Get("parameters") + for _, unionName := range []string{"anyOf", "oneOf"} { + union := parameters.Get(unionName) + if !union.IsArray() { + continue + } + for _, branch := range union.Array() { + branchType := branch.Get("type") + if branchType.Type == gjson.String { + if !strings.EqualFold(strings.TrimSpace(branchType.String()), "object") { + return true + } + continue + } + if !branchType.IsArray() { + // Without an explicit object type, the branch may accept non-object + // values even when it only declares object-specific keywords. + return true + } + allowedTypes := branchType.Array() + if len(allowedTypes) == 0 { + return true + } + for _, allowedType := range allowedTypes { + if allowedType.Type != gjson.String || !strings.EqualFold(strings.TrimSpace(allowedType.String()), "object") { + return true + } + } + } + } + return false } func sanitizeXAIInputEncryptedContent(body []byte) []byte { diff --git a/internal/runtime/executor/xai_executor_test.go b/internal/runtime/executor/xai_executor_test.go index 90a7ca44..576b262f 100644 --- a/internal/runtime/executor/xai_executor_test.go +++ b/internal/runtime/executor/xai_executor_test.go @@ -2313,6 +2313,45 @@ func TestNormalizeXAITools_SimplifiesCodexAppAutomationUpdateSchema(t *testing.T } } +func TestNormalizeXAITools_SimplifiesFlattenedAndInvalidRootSchemas(t *testing.T) { + body := []byte(`{"tools":[{"type":"function","name":"codex_app__automation_update","strict":true,"parameters":{"oneOf":[{"type":"object","properties":{"action":{"type":"string"}},"required":["action"]},{"type":"null"}]}},{"type":"function","name":"nullable_lookup","strict":true,"parameters":{"anyOf":[{"type":"object","properties":{"query":{"type":"string"}}},{"type":["object","null"]}]}},{"type":"custom","name":"nullable_custom","strict":true,"parameters":{"oneOf":[{"type":"object"},{"type":"null"}]}},{"type":"function","name":"echo_tool","strict":true,"parameters":{"type":"object","properties":{"message":{"type":"string"}},"required":["message"],"additionalProperties":false}}]}`) + out := normalizeXAITools(body) + + tools := gjson.GetBytes(out, "tools").Array() + if len(tools) != 4 { + t.Fatalf("tools length = %d, want 4; body=%s", len(tools), string(out)) + } + for index, wantName := range []string{"codex_app__automation_update", "nullable_lookup", "nullable_custom"} { + tool := tools[index] + if got := tool.Get("name").String(); got != wantName { + t.Fatalf("tools.%d.name = %q, want %q; body=%s", index, got, wantName, string(out)) + } + if got := tool.Get("type").String(); got != xaiFunctionToolType { + t.Fatalf("tools.%d type = %q, want function; body=%s", index, got, string(out)) + } + if got := tool.Get("parameters.type").String(); got != "object" { + t.Fatalf("tools.%d parameters.type = %q, want object; body=%s", index, got, string(out)) + } + if tool.Get("parameters.additionalProperties").Type != gjson.True { + t.Fatalf("tools.%d parameters should allow additionalProperties: %s", index, string(out)) + } + if tool.Get("strict").Type != gjson.False { + t.Fatalf("tools.%d strict = %s, want false; body=%s", index, tool.Get("strict").Raw, string(out)) + } + } + + echoTool := tools[3] + if got := echoTool.Get("parameters.properties.message.type").String(); got != "string" { + t.Fatalf("echo_tool schema changed, message type = %q; body=%s", got, string(out)) + } + if echoTool.Get("strict").Type != gjson.True { + t.Fatalf("echo_tool strict changed: %s", string(out)) + } + if echoTool.Get("parameters.additionalProperties").Type != gjson.False { + t.Fatalf("echo_tool additionalProperties changed: %s", string(out)) + } +} + func TestNormalizeXAITools_QualifiesSameNamedNamespaceTools(t *testing.T) { body := []byte(`{ "tools":[ @@ -2508,9 +2547,37 @@ func TestXAIFunctionParametersNeedSimplification(t *testing.T) { if xaiFunctionParametersNeedSimplification(auto, "") { t.Fatal("top-level automation_update should not need simplification") } + flattened := gjson.Parse(`{"type":"function","name":"codex_app__automation_update","parameters":{"type":"object"}}`) + if !xaiFunctionParametersNeedSimplification(flattened, "") { + t.Fatal("flattened codex_app__automation_update should need simplification") + } custom := gjson.Parse(`{"type":"custom","name":"automation_update","parameters":{"type":"object"}}`) if xaiFunctionParametersNeedSimplification(custom, "codex_app") { - t.Fatal("custom codex_app.automation_update should not need simplification") + t.Fatal("custom codex_app.automation_update with an object schema should not need simplification") + } + invalidCustom := gjson.Parse(`{"type":"custom","name":"nullable_lookup","parameters":{"oneOf":[{"type":"object"},{"type":"null"}]}}`) + if !xaiFunctionParametersNeedSimplification(invalidCustom, "") { + t.Fatal("custom tool normalized to a function should simplify an invalid root union") + } + invalidOneOf := gjson.Parse(`{"type":"function","name":"nullable_lookup","parameters":{"oneOf":[{"type":"object"},{"type":"null"}]}}`) + if !xaiFunctionParametersNeedSimplification(invalidOneOf, "") { + t.Fatal("root oneOf with a non-object branch should need simplification") + } + invalidAnyOf := gjson.Parse(`{"type":"function","name":"nullable_lookup","parameters":{"anyOf":[{"type":"object"},{"type":["object","null"]}]}}`) + if !xaiFunctionParametersNeedSimplification(invalidAnyOf, "") { + t.Fatal("root anyOf with a non-object type should need simplification") + } + untypedBranch := gjson.Parse(`{"type":"function","name":"nullable_lookup","parameters":{"oneOf":[{"type":"object"},{"const":null}]}}`) + if !xaiFunctionParametersNeedSimplification(untypedBranch, "") { + t.Fatal("root union with an untyped branch should need simplification") + } + objectUnion := gjson.Parse(`{"type":"function","name":"lookup","parameters":{"oneOf":[{"type":"object"},{"type":"object"}]}}`) + if xaiFunctionParametersNeedSimplification(objectUnion, "") { + t.Fatal("root union containing only object branches should not need simplification") + } + nestedUnion := gjson.Parse(`{"type":"function","name":"lookup","parameters":{"type":"object","properties":{"value":{"oneOf":[{"type":"string"},{"type":"null"}]}}}}`) + if xaiFunctionParametersNeedSimplification(nestedUnion, "") { + t.Fatal("nested union should not need root schema simplification") } safe := gjson.Parse(`{"type":"function","name":"exec_command","parameters":{"type":"object","properties":{"cmd":{"type":"string"}}}}`) if xaiFunctionParametersNeedSimplification(safe, "codex_app") {