Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -77,28 +77,66 @@ enum ToolSchemaNormalization {
}
}

// A type that is PRESENT but not a string crashes `| upper` just like a
// missing one. The common real-world shape is the JSON-Schema array form
// for nullable fields — `"type": ["string","null"]` — which Pydantic
// emits for every Optional[...] tool parameter. Collapse it to a single
// representative string (never delete the key: a node whose only content
// is its type would not be refilled below and would crash anyway).
// Nullability is preserved losslessly: the gemma template natively
// renders the standard `nullable` key, so collapsing away a "null"
// member sets it (without clobbering an explicit value).
if let t = dict["type"], !(t is String) {
let members = (t as? [Any])?.compactMap { $0 as? String } ?? []
if members.contains("null"), members.contains(where: { $0 != "null" }),
dict["nullable"] == nil {
dict["nullable"] = true
}
dict["type"] = collapsedType(members: members, in: dict)
}
Comment on lines +89 to +96

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 [INFO] ⚡ Array type casting performed twice for same value

💡 Suggestion: Cache the compactMap result: let members = (t as? [Any])?.compactMap { $0 as? String } ?? []; then reuse members in both conditions

📊 Score: 2×3 = 6 · Category: Repeated work

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid — fixed: members is computed once at the call site and passed into collapsedType(members:in:), so the nullable check and the collapse share a single compactMap.

Comment on lines +89 to +96

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 [INFO] 🧩 Complex nullable detection logic could be simplified

💡 Suggestion: Consider extracting the nullable detection into a simple helper function or inline the logic more directly

📊 Score: 2×3 = 6 · Category: over-abstraction

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed by the same restructure: one members extraction feeding both the nullable check and the collapse; no helper needed beyond that.

Comment on lines +89 to +96

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 [INFO] ⚡ compactMap creates new array for every type field without pre-allocation

💡 Suggestion: Pre-allocate array capacity or use lazy evaluation if members array is typically small

📊 Score: 2×3 = 6 · Category: Unbounded allocations

Comment on lines +89 to +96

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 [INFO] 🧩 Complex nullable detection logic could be simplified with early return

💡 Suggestion: Extract nullable detection to a separate function or use early return to reduce nesting and improve readability

📊 Score: 2×3 = 6 · Category: over-abstraction


let looksLikeSchemaNode =
dict["properties"] != nil || dict["items"] != nil ||
dict["additionalProperties"] != nil ||
dict["enum"] != nil || dict["description"] != nil ||
dict["anyOf"] != nil || dict["oneOf"] != nil || dict["allOf"] != nil
if dict["type"] == nil, looksLikeSchemaNode {
if dict["properties"] != nil || dict["additionalProperties"] != nil {
dict["type"] = "object"
} else if dict["items"] != nil {
dict["type"] = "array"
} else if let unionType = unionMemberType(dict) {
// anyOf/oneOf/allOf without a parent type: borrow the first concrete
// member type (skipping "null") rather than mislabelling a union as a
// string. The template still gets a usable type and can't crash.
dict["type"] = unionType
} else {
dict["type"] = "string"
}
dict["type"] = inferredType(for: dict)
}
return dict
}

/// Collapse a non-string `type` value (pre-extracted string members of the
/// array form) to one renderable string: the first concrete (non-"null")
/// member, the lone "null" when that is all the array declares, else fall
/// back to structural inference.
private static func collapsedType(members: [String], in dict: [String: Any]) -> String {
if let concrete = members.first(where: { $0 != "null" }) {
return concrete
}
if let nullOnly = members.first {
return nullOnly
}
return inferredType(for: dict)
}

/// Structural default for a schema node's `type`: object when it has
/// properties, array when it has items, a union member's type when it is an
/// anyOf/oneOf/allOf (skipping "null" — mislabelling a union as a string
Comment on lines +113 to +125

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 [INFO] 🧩 Type inference logic duplicated between collapsedType and inferredType functions

💡 Suggestion: Consider having collapsedType delegate directly to inferredType for the fallback case instead of duplicating the structural inference logic

📊 Score: 2×2 = 4 · Category: duplicate logic

/// would be wrong), otherwise string.
private static func inferredType(for dict: [String: Any]) -> String {
if dict["properties"] != nil || dict["additionalProperties"] != nil {
return "object"
}
if dict["items"] != nil {
return "array"
}
if let unionType = unionMemberType(dict) {
return unionType
}
return "string"
}

/// Derive a representative `type` for a union node from the first member that
/// declares a concrete, non-"null" type. Returns nil when none is found.
private static func unionMemberType(_ dict: [String: Any]) -> String? {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,4 +102,132 @@ extension ToolSchemaNormalizationTests {
body.append(Data(count: ToolSchemaNormalization.maxNormalizationBytes))
#expect(ToolSchemaNormalization.ensureParameterTypes(in: body) == body)
}
// MARK: - Array-typed (nullable) `type` values — the second DAR-130 class.
// `"type": ["string","null"]` is what Pydantic emits for Optional[...] tool
// parameters; the gemma template's `| upper` crashed on the list ("upper
// filter requires string", reproduced on prod 2026-06-10).
Comment on lines +105 to +108

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 [INFO] 🧩 Verbose multi-line comment could be more concise

💡 Suggestion: Condense the comment to focus on the key issue: 'Array-typed nullable fields crash gemma template's | upper filter'

📊 Score: 1×2 = 2 · Category: over-abstraction

Comment on lines +105 to +108

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 [INFO] 🧩 Verbose multi-line comment could be more concise

💡 Suggestion: Condense the comment to focus on the key issue: array-typed fields crash gemma template's upper filter

📊 Score: 1×2 = 2 · Category: over-abstraction


@Test func collapsesNullableArrayTypeToConcreteMember() throws {
let body = #"""
{"tools":[{"type":"function","function":{"name":"get_weather",
"parameters":{"type":"object","properties":{
"city":{"type":["string","null"],"description":"city"}},
"required":["city"]}}}]}
"""#.data(using: .utf8)!

let out = ToolSchemaNormalization.ensureParameterTypes(in: body)
let function = try #require((parse(out)["tools"] as? [[String: Any]])?[0]["function"] as? [String: Any])
let props = try #require((function["parameters"] as? [String: Any])?["properties"] as? [String: Any])
let city = try #require(props["city"] as? [String: Any])
#expect(city["type"] as? String == "string")
// Nullability preserved losslessly via the template-supported key.
#expect(city["nullable"] as? Bool == true)
}

@Test func collapsesArrayTypeSkippingLeadingNull() throws {
let body = #"""
{"tools":[{"type":"function","function":{"name":"f",
"parameters":{"type":"object","properties":{
"n":{"type":["null","integer"]}}}}}]}
"""#.data(using: .utf8)!

let out = ToolSchemaNormalization.ensureParameterTypes(in: body)
let function = try #require((parse(out)["tools"] as? [[String: Any]])?[0]["function"] as? [String: Any])
let props = try #require((function["parameters"] as? [String: Any])?["properties"] as? [String: Any])
#expect((props["n"] as? [String: Any])?["type"] as? String == "integer")
}

@Test func collapsesNullOnlyArrayTypeToNullString() throws {
// ["null"] has no concrete member — keep the honest "null", which still
// renders (it is a string for `| upper`).
let body = #"""
{"tools":[{"type":"function","function":{"name":"f",
"parameters":{"type":"object","properties":{
"x":{"type":["null"]}}}}}]}
"""#.data(using: .utf8)!

let out = ToolSchemaNormalization.ensureParameterTypes(in: body)
let function = try #require((parse(out)["tools"] as? [[String: Any]])?[0]["function"] as? [String: Any])
let props = try #require((function["parameters"] as? [String: Any])?["properties"] as? [String: Any])
#expect((props["x"] as? [String: Any])?["type"] as? String == "null")
}

@Test func collapsesArrayTypeInNestedObjectAndItems() throws {
let body = #"""
{"tools":[{"type":"function","function":{"name":"set_alarm",
"parameters":{"type":"object","properties":{
"opts":{"type":"object","properties":{"snooze":{"type":["integer","null"]}}},
"tags":{"type":"array","items":{"type":["string","null"]}}}}}}]}
"""#.data(using: .utf8)!

let out = ToolSchemaNormalization.ensureParameterTypes(in: body)
let function = try #require((parse(out)["tools"] as? [[String: Any]])?[0]["function"] as? [String: Any])
let props = try #require((function["parameters"] as? [String: Any])?["properties"] as? [String: Any])
let snooze = try #require(((props["opts"] as? [String: Any])?["properties"] as? [String: Any])?["snooze"] as? [String: Any])
#expect(snooze["type"] as? String == "integer")
let items = try #require((props["tags"] as? [String: Any])?["items"] as? [String: Any])
#expect(items["type"] as? String == "string")
}

@Test func malformedNonStringTypeFallsBackToStructuralInference() throws {
// A numeric `type` is invalid JSON Schema; repair it from structure
// (properties present → object) instead of leaving the list/number for
// the template to choke on.
let body = #"""
{"tools":[{"type":"function","function":{"name":"f",
"parameters":{"type":"object","properties":{
"cfg":{"type":42,"properties":{"k":{"type":"string"}}},
"v":{"type":7,"description":"v"}}}}}]}
"""#.data(using: .utf8)!

let out = ToolSchemaNormalization.ensureParameterTypes(in: body)
let function = try #require((parse(out)["tools"] as? [[String: Any]])?[0]["function"] as? [String: Any])
let props = try #require((function["parameters"] as? [String: Any])?["properties"] as? [String: Any])
#expect((props["cfg"] as? [String: Any])?["type"] as? String == "object")
#expect((props["v"] as? [String: Any])?["type"] as? String == "string")
}
@Test func unionMemberWithArrayTypeStillDrivesParentInference() throws {
// Ordering is load-bearing: members collapse BEFORE the parent's union
// inference, so a first member declaring ["string","null"] must yield a
// "string" parent type (not fall through to the default).
let body = #"""
{"tools":[{"type":"function","function":{"name":"f",
"parameters":{"type":"object","properties":{
"u":{"anyOf":[{"type":["string","null"]},{"type":"integer"}],"description":"u"}}}}}]}
"""#.data(using: .utf8)!

let out = ToolSchemaNormalization.ensureParameterTypes(in: body)
let function = try #require((parse(out)["tools"] as? [[String: Any]])?[0]["function"] as? [String: Any])
let props = try #require((function["parameters"] as? [String: Any])?["properties"] as? [String: Any])
#expect((props["u"] as? [String: Any])?["type"] as? String == "string")
}

@Test func collapsesArrayTypeOnTopLevelParametersNode() throws {
// The template also renders params['type'] | upper at the top level.
let body = #"""
{"tools":[{"type":"function","function":{"name":"f",
"parameters":{"type":["object","null"],"properties":{"q":{"type":"string"}}}}}]}
"""#.data(using: .utf8)!

let out = ToolSchemaNormalization.ensureParameterTypes(in: body)
let function = try #require((parse(out)["tools"] as? [[String: Any]])?[0]["function"] as? [String: Any])
let params = try #require(function["parameters"] as? [String: Any])
#expect(params["type"] as? String == "object")
#expect(params["nullable"] as? Bool == true)
}

@Test func collapsesArrayTypeInsideAdditionalPropertiesSchema() throws {
let body = #"""
{"tools":[{"type":"function","function":{"name":"f",
"parameters":{"type":"object","properties":{
"kv":{"type":"object","additionalProperties":{"type":["number","null"]}}}}}}]}
"""#.data(using: .utf8)!

let out = ToolSchemaNormalization.ensureParameterTypes(in: body)
let function = try #require((parse(out)["tools"] as? [[String: Any]])?[0]["function"] as? [String: Any])
let props = try #require((function["parameters"] as? [String: Any])?["properties"] as? [String: Any])
let addl = try #require((props["kv"] as? [String: Any])?["additionalProperties"] as? [String: Any])
#expect(addl["type"] as? String == "number")
#expect(addl["nullable"] as? Bool == true)
}
}
Loading