Skip to content
Closed
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
16 changes: 16 additions & 0 deletions Sources/Conduit/Core/Protocols/Generable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,22 @@ extension Array: Generable where Element: Generable {
}
}

// MARK: Dictionary

extension Dictionary: Generable where Key == String, Value: Generable {
/// A representation of partially generated content
public typealias PartiallyGenerated = Self

/// An instance of the generation schema.
public static var generationSchema: GenerationSchema {
let valueSchema = Value.generationSchema
return GenerationSchema.primitive(
[String: Value].self,
node: .dictionary(GenerationSchema.DictionaryNode(description: nil, values: valueSchema.root))
Comment on lines +282 to +285
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Carry value defs when building dictionary schema

When Value.generationSchema.root is a $ref (for example, Value is another @Generable type), this code stores that ref in additionalProperties but drops Value.generationSchema.defs by returning GenerationSchema.primitive(...). GenerationSchema.Property.buildNode later only merges schema.defs, so schemas for [String: Value] can emit dangling $refs with missing definitions, which breaks nested dictionary value typing in structured generation/tool schemas.

Useful? React with 👍 / 👎.

)
}
}

// MARK: Never

extension Never: Generable {
Expand Down
15 changes: 15 additions & 0 deletions Sources/Conduit/Core/Types/ConvertibleFromGeneratedContent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,18 @@ where Element: ConvertibleFromGeneratedContent {
self = try elements.map { try Element($0) }
}
}

extension Dictionary: ConvertibleFromGeneratedContent & SendableMetatype
where Key == String, Value: ConvertibleFromGeneratedContent {
/// Creates an instance with the content.
public init(_ content: GeneratedContent) throws {
guard case .structure(let properties, _) = content.kind else {
throw GeneratedContentConversionError.typeMismatch
}
var result: [String: Value] = [:]
for (key, value) in properties {
result[key] = try Value(value)
}
self = result
}
}
15 changes: 15 additions & 0 deletions Sources/Conduit/Core/Types/ConvertibleToGeneratedContent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,18 @@ extension Array: ConvertibleToGeneratedContent where Element: ConvertibleToGener
GeneratedContent(elements: self.map { $0.generatedContent })
}
}

extension Dictionary: PromptRepresentable where Key == String, Value: ConvertibleToGeneratedContent {}
extension Dictionary: InstructionsRepresentable where Key == String, Value: ConvertibleToGeneratedContent {}
extension Dictionary: ConvertibleToGeneratedContent where Key == String, Value: ConvertibleToGeneratedContent {
/// An instance that represents the generated content.
public var generatedContent: GeneratedContent {
var props: [String: GeneratedContent] = [:]
var keys: [String] = []
for (key, value) in self {
props[key] = value.generatedContent
keys.append(key)
}
return GeneratedContent(kind: .structure(properties: props, orderedKeys: keys))
}
}
39 changes: 32 additions & 7 deletions Sources/Conduit/Core/Types/GenerationSchema.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ public struct GenerationSchema: Sendable, Codable, CustomDebugStringConvertible
indirect enum Node: Sendable, Codable {
case object(ObjectNode)
case array(ArrayNode)
case dictionary(DictionaryNode)
case string(StringNode)
case number(NumberNode)
case boolean
Expand Down Expand Up @@ -89,6 +90,13 @@ public struct GenerationSchema: Sendable, Codable, CustomDebugStringConvertible
try container.encode(max, forKey: .maxItems)
}

case .dictionary(let dict):
try container.encode("object", forKey: .type)
if let desc = dict.description {
try container.encode(desc, forKey: .description)
}
try container.encode(dict.values, forKey: .additionalProperties)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Keep Anthropic tool schema compatibility for dictionaries

This new encoding writes additionalProperties as a nested schema object, but Anthropic conversion currently reads additionalProperties only as Bool (convertToInputSchema and convertToPropertySchema in AnthropicProvider+Helpers.swift), so dictionary constraints are silently dropped to an unconstrained object for Anthropic tool calls. As a result, dictionary-typed tool arguments are not validated to the declared value type and can produce downstream conversion failures.

Useful? React with 👍 / 👎.


case .string(let str):
try container.encode("string", forKey: .type)
if let desc = str.description {
Expand Down Expand Up @@ -145,14 +153,22 @@ public struct GenerationSchema: Sendable, Codable, CustomDebugStringConvertible

switch type {
case "object":
let propsContainer = try container.nestedContainer(keyedBy: DynamicCodingKey.self, forKey: .properties)
var properties: [String: Node] = [:]
for key in propsContainer.allKeys {
properties[key.stringValue] = try propsContainer.decode(Node.self, forKey: key)
// Distinguish between a regular object (has "properties") and a dictionary (has "additionalProperties" as a schema)
if container.contains(.properties) {
let propsContainer = try container.nestedContainer(keyedBy: DynamicCodingKey.self, forKey: .properties)
var properties: [String: Node] = [:]
for key in propsContainer.allKeys {
properties[key.stringValue] = try propsContainer.decode(Node.self, forKey: key)
}
let requiredArray = try container.decodeIfPresent([String].self, forKey: .required) ?? []
let required = Set(requiredArray)
self = .object(ObjectNode(description: description, properties: properties, required: required))
} else if let valuesNode = try? container.decode(Node.self, forKey: .additionalProperties) {
self = .dictionary(DictionaryNode(description: description, values: valuesNode))
} else {
// Empty object with no properties
self = .object(ObjectNode(description: description, properties: [:], required: []))
}
let requiredArray = try container.decodeIfPresent([String].self, forKey: .required) ?? []
let required = Set(requiredArray)
self = .object(ObjectNode(description: description, properties: properties, required: required))

case "array":
let items = try container.decode(Node.self, forKey: .items)
Expand Down Expand Up @@ -203,6 +219,11 @@ public struct GenerationSchema: Sendable, Codable, CustomDebugStringConvertible
var maxItems: Int?
}

struct DictionaryNode: Sendable, Codable {
var description: String?
var values: Node
}

struct StringNode: Sendable, Codable {
var description: String?
var pattern: String?
Expand Down Expand Up @@ -256,6 +277,8 @@ public struct GenerationSchema: Sendable, Codable, CustomDebugStringConvertible
return "object(\(obj.properties.count) properties)"
case .array(let arr):
return "array(items: \(debugString(for: arr.items, indent: 0)))"
case .dictionary(let dict):
return "dictionary(values: \(debugString(for: dict.values, indent: 0)))"
case .string(let str):
if let choices = str.enumChoices {
return "string(enum: \(choices))"
Expand Down Expand Up @@ -526,6 +549,8 @@ public struct GenerationSchema: Sendable, Codable, CustomDebugStringConvertible
}
case .array(let arr):
try validateRefs(arr.items, defs: defs, undefinedRefs: &undefinedRefs)
case .dictionary(let dict):
try validateRefs(dict.values, defs: defs, undefinedRefs: &undefinedRefs)
case .anyOf(let nodes):
for n in nodes {
try validateRefs(n, defs: defs, undefinedRefs: &undefinedRefs)
Expand Down