Skip to content

Commit bb35268

Browse files
BLannooBruno Lannoo
andauthored
🐛 Fix Anthropic json schema validation error (#457)
Co-authored-by: Bruno Lannoo <[email protected]>
1 parent 213a425 commit bb35268

File tree

2 files changed

+287
-13
lines changed

2 files changed

+287
-13
lines changed
Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
package ai.koog.integration.tests
2+
3+
import ai.koog.agents.core.agent.AIAgent
4+
import ai.koog.agents.core.tools.SimpleTool
5+
import ai.koog.agents.core.tools.ToolArgs
6+
import ai.koog.agents.core.tools.ToolDescriptor
7+
import ai.koog.agents.core.tools.ToolParameterDescriptor
8+
import ai.koog.agents.core.tools.ToolParameterType
9+
import ai.koog.agents.core.tools.ToolRegistry
10+
import ai.koog.agents.features.eventHandler.feature.EventHandler
11+
import ai.koog.integration.tests.utils.TestUtils.readTestAnthropicKeyFromEnv
12+
import ai.koog.prompt.executor.clients.anthropic.AnthropicModels
13+
import ai.koog.prompt.executor.llms.all.simpleAnthropicExecutor
14+
import kotlinx.coroutines.runBlocking
15+
import kotlinx.serialization.Serializable
16+
import org.junit.jupiter.api.BeforeAll
17+
import org.junit.jupiter.api.Test
18+
import org.junit.jupiter.api.Assumptions.assumeTrue
19+
import kotlin.test.assertNotNull
20+
import kotlin.test.assertTrue
21+
22+
/**
23+
* Integration test for verifying the fix for the Anthropic API JSON schema validation error
24+
* when using complex nested structures in tool parameters.
25+
*
26+
* The issue was in the AnthropicLLMClient.kt file, specifically in the getTypeMapForParameter() function
27+
* that converts ToolDescriptor objects to JSON schemas for the Anthropic API.
28+
*
29+
* The problem was that when processing ToolParameterType.Object, the function created invalid nested structures
30+
* by placing type information under a "type" key, resulting in invalid schema structures like:
31+
* {
32+
* "type": {"type": "string"} // Invalid nesting
33+
* }
34+
*
35+
* This test verifies that the fix works by creating an agent with the Anthropic API and a tool
36+
* with complex nested structures, and then running it with a sample input.
37+
*/
38+
class AnthropicSchemaValidationIntegrationTest {
39+
40+
companion object {
41+
private var anthropicApiKey: String? = null
42+
private var apiKeyAvailable = false
43+
44+
@BeforeAll
45+
@JvmStatic
46+
fun setup() {
47+
try {
48+
anthropicApiKey = readTestAnthropicKeyFromEnv()
49+
// Check that the API key is not empty or blank
50+
apiKeyAvailable = !anthropicApiKey.isNullOrBlank()
51+
if (!apiKeyAvailable) {
52+
println("Anthropic API key is empty or blank")
53+
println("Tests requiring Anthropic API will be skipped")
54+
}
55+
} catch (e: Exception) {
56+
println("Anthropic API key not available: ${e.message}")
57+
println("Tests requiring Anthropic API will be skipped")
58+
apiKeyAvailable = false
59+
}
60+
}
61+
}
62+
63+
/**
64+
* Address type enum.
65+
*/
66+
@Serializable
67+
enum class AddressType {
68+
HOME, WORK, OTHER
69+
}
70+
71+
/**
72+
* An address with multiple fields.
73+
*/
74+
@Serializable
75+
data class Address(
76+
val type: AddressType,
77+
val street: String,
78+
val city: String,
79+
val state: String,
80+
val zipCode: String
81+
)
82+
83+
/**
84+
* A user profile with nested structures.
85+
*/
86+
@Serializable
87+
data class UserProfile(
88+
val name: String,
89+
val email: String,
90+
val addresses: List<Address>
91+
)
92+
93+
/**
94+
* Arguments for the complex nested tool.
95+
*/
96+
@Serializable
97+
data class ComplexNestedToolArgs(
98+
val profile: UserProfile
99+
) : ToolArgs
100+
101+
/**
102+
* A complex nested tool that demonstrates the JSON schema validation error.
103+
* This tool has parameters with complex nested structures that would trigger
104+
* the error in the Anthropic API before the fix.
105+
*/
106+
object ComplexNestedTool : SimpleTool<ComplexNestedToolArgs>() {
107+
override val argsSerializer = ComplexNestedToolArgs.serializer()
108+
109+
override val descriptor = ToolDescriptor(
110+
name = "complex_nested_tool",
111+
description = "A tool that processes user profiles with complex nested structures.",
112+
requiredParameters = listOf(
113+
ToolParameterDescriptor(
114+
name = "profile",
115+
description = "The user profile to process",
116+
type = ToolParameterType.Object(
117+
properties = listOf(
118+
ToolParameterDescriptor(
119+
name = "name",
120+
description = "The user's full name",
121+
type = ToolParameterType.String
122+
),
123+
ToolParameterDescriptor(
124+
name = "email",
125+
description = "The user's email address",
126+
type = ToolParameterType.String
127+
),
128+
ToolParameterDescriptor(
129+
name = "addresses",
130+
description = "The user's addresses",
131+
type = ToolParameterType.List(
132+
ToolParameterType.Object(
133+
properties = listOf(
134+
ToolParameterDescriptor(
135+
name = "type",
136+
description = "The type of address (HOME, WORK, or OTHER)",
137+
type = ToolParameterType.Enum(AddressType.entries.map { it.name }.toTypedArray())
138+
),
139+
ToolParameterDescriptor(
140+
name = "street",
141+
description = "The street address",
142+
type = ToolParameterType.String
143+
),
144+
ToolParameterDescriptor(
145+
name = "city",
146+
description = "The city",
147+
type = ToolParameterType.String
148+
),
149+
ToolParameterDescriptor(
150+
name = "state",
151+
description = "The state or province",
152+
type = ToolParameterType.String
153+
),
154+
ToolParameterDescriptor(
155+
name = "zipCode",
156+
description = "The ZIP or postal code",
157+
type = ToolParameterType.String
158+
)
159+
),
160+
requiredProperties = listOf("type", "street", "city", "state", "zipCode")
161+
)
162+
)
163+
)
164+
),
165+
requiredProperties = listOf("name", "email", "addresses")
166+
)
167+
)
168+
)
169+
)
170+
171+
override suspend fun doExecute(args: ComplexNestedToolArgs): String {
172+
// Process the user profile
173+
val profile = args.profile
174+
val addressesInfo = profile.addresses.joinToString("\n") { address ->
175+
"- ${address.type} Address: ${address.street}, ${address.city}, ${address.state} ${address.zipCode}"
176+
}
177+
178+
return """
179+
Successfully processed user profile:
180+
Name: ${profile.name}
181+
Email: ${profile.email}
182+
Addresses:
183+
$addressesInfo
184+
""".trimIndent()
185+
}
186+
}
187+
188+
/**
189+
* Test that verifies the fix for the Anthropic API JSON schema validation error
190+
* when using complex nested structures in tool parameters.
191+
*
192+
* Before the fix, this test would fail with an error like:
193+
* "tools.0.custom.input_schema: JSON schema is invalid. It must match JSON Schema draft 2020-12"
194+
*
195+
* After the fix, the test should pass, demonstrating that the Anthropic API
196+
* can now correctly handle complex nested structures in tool parameters.
197+
*
198+
* Note: This test requires a valid Anthropic API key to be set in the environment variable
199+
* ANTHROPIC_API_TEST_KEY. If the key is not available, the test will be skipped.
200+
*/
201+
@Test
202+
fun integration_testAnthropicComplexNestedStructures() {
203+
// Skip the test if the Anthropic API key is not available
204+
assumeTrue(apiKeyAvailable, "Anthropic API key is not available")
205+
206+
runBlocking<Unit> {
207+
// Create an agent with the Anthropic API and the complex nested tool
208+
val agent = AIAgent(
209+
executor = simpleAnthropicExecutor(anthropicApiKey!!),
210+
llmModel = AnthropicModels.Sonnet_3_7,
211+
systemPrompt = "You are a helpful assistant that can process user profiles. Please use the complex_nested_tool to process the user profile I provide.",
212+
toolRegistry = ToolRegistry {
213+
tool(ComplexNestedTool)
214+
},
215+
installFeatures = {
216+
install(EventHandler) {
217+
onAgentRunError { eventContext ->
218+
println("ERROR: ${eventContext.throwable.javaClass.simpleName}(${eventContext.throwable.message})")
219+
println(eventContext.throwable.stackTraceToString())
220+
true
221+
}
222+
onToolCall { eventContext ->
223+
println("Calling tool: ${eventContext.tool.name}")
224+
println("Arguments: ${eventContext.toolArgs.toString().take(100)}...")
225+
}
226+
}
227+
}
228+
)
229+
230+
// Run the agent with a request to process a user profile
231+
val result = agent.run("""
232+
Please process this user profile:
233+
234+
Name: John Doe
235+
236+
Addresses:
237+
1. HOME: 123 Main St, Springfield, IL 62701
238+
2. WORK: 456 Business Ave, Springfield, IL 62701
239+
""".trimIndent())
240+
241+
// Verify the result
242+
println("\nResult: $result")
243+
assertNotNull(result, "Result should not be null")
244+
assertTrue(result.isNotBlank(), "Result should not be empty or blank")
245+
246+
// Check that the result contains expected information
247+
assertTrue(result.lowercase().contains("john doe"), "Result should contain the user's name")
248+
assertTrue(result.lowercase().contains("[email protected]"), "Result should contain the user's email")
249+
assertTrue(result.lowercase().contains("main st"), "Result should contain the home address street")
250+
assertTrue(result.lowercase().contains("business ave"), "Result should contain the work address street")
251+
}
252+
}
253+
}

prompt/prompt-executor/prompt-executor-clients/prompt-executor-anthropic-client/src/commonMain/kotlin/ai/koog/prompt/executor/clients/anthropic/AnthropicLLMClient.kt

Lines changed: 34 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -399,19 +399,40 @@ public open class AnthropicLLMClient(
399399
)
400400
)
401401

402-
is ToolParameterType.Object -> JsonObject(
403-
mapOf(
404-
"type" to JsonPrimitive("object"),
405-
"properties" to JsonObject(type.properties.associate {
406-
it.name to JsonObject(
407-
mapOf(
408-
"type" to getTypeMapForParameter(it.type),
409-
"description" to JsonPrimitive(it.description)
410-
)
411-
)
412-
})
413-
)
414-
)
402+
is ToolParameterType.Object -> {
403+
// Create properties map with proper type information
404+
val propertiesMap = mutableMapOf<String, JsonElement>()
405+
406+
for (prop in type.properties) {
407+
// Get type information for the property
408+
val typeInfo = getTypeMapForParameter(prop.type)
409+
410+
// Create a map with all type properties and description
411+
val propMap = mutableMapOf<String, JsonElement>()
412+
for (entry in typeInfo.entries) {
413+
propMap[entry.key] = entry.value
414+
}
415+
propMap["description"] = JsonPrimitive(prop.description)
416+
417+
// Add to properties map
418+
propertiesMap[prop.name] = JsonObject(propMap)
419+
}
420+
421+
// Create the final object schema
422+
val objectMap = mutableMapOf<String, JsonElement>()
423+
objectMap["type"] = JsonPrimitive("object")
424+
objectMap["properties"] = JsonObject(propertiesMap)
425+
426+
// Add required field if requiredProperties is not empty
427+
if (type.requiredProperties.isNotEmpty()) {
428+
objectMap["required"] = JsonArray(type.requiredProperties.map { JsonPrimitive(it) })
429+
}
430+
431+
// Add additionalProperties for strict validation
432+
objectMap["additionalProperties"] = JsonPrimitive(type.additionalProperties ?: false)
433+
434+
JsonObject(objectMap)
435+
}
415436
}
416437
}
417438

0 commit comments

Comments
 (0)