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
This commit is contained in:
Luis Pater
2026-07-16 02:05:50 +08:00
parent 71b5beb8b9
commit b6ce0beecd
2 changed files with 119 additions and 10 deletions
+51 -9
View File
@@ -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 {
+68 -1
View File
@@ -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") {