diff --git a/README.md b/README.md index 0568979..c859f4a 100644 --- a/README.md +++ b/README.md @@ -96,14 +96,17 @@ fun main() { It can also use tools: ```kotlin -@Serializable +@SerializableTool( + name = "Calculator", + description = "Calculates the arithmetic outcome of an operation when given the arguments a and b" +) data class Calculator( val operation: Operation, val a: Double, val b: Double -) { +): UsableTool { - @Suppress("unused") // will be used by Anthropic :) + @Suppress("unused") // it is used, but by Anthropic, so we skip the warning enum class Operation( val calculate: (a: Double, b: Double) -> Double ) { @@ -113,31 +116,39 @@ data class Calculator( DIVIDE({ a, b -> a / b }) } - fun calculate() = operation.calculate(a, b) + override fun use(toolUseId: String) = ToolResult( + toolUseId, + operation.calculate(a, b).toString() + ) } -fun main() { - val client = Anthropic() +fun main() = runBlocking { - val calculatorTool = Tool( - description = "Perform basic arithmetic operations" - ) + val client = Anthropic { + tool() + } - val response = runBlocking { - client.messages.create { - +Message { - +"What's 15 multiplied by 7?" - } - tools = listOf(calculatorTool) - toolChoice = ToolChoice.Any() - } + val conversation = mutableListOf() + conversation += Message { +"What's 15 multiplied by 7?" } + + val response1 = client.messages.create { + messages = conversation + useTools() } + conversation += response1.asMessage() - val toolUse = response.content[0] as ToolUse - val calculator = toolUse.input() - val result = calculator.calculate() // we are doing the job for LLM here - println(result) + println((response1.content[0] as Text).text) + val toolUse = response1.content[1] as ToolUse + val result = toolUse.use() // we are doing the calculation job for Claude here + + conversation += Message { +result } + + val response2 = client.messages.create { + messages = conversation + useTools() + } + println((response2.content[0] as Text).text) } ``` diff --git a/build.gradle.kts b/build.gradle.kts index 171cc36..3844485 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,7 +6,6 @@ import org.gradle.api.tasks.testing.logging.TestLogEvent import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.dsl.KotlinVersion -import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile plugins { alias(libs.plugins.kotlin.multiplatform) @@ -47,6 +46,7 @@ kotlin { //explicitApi() // check with serialization? jvm { testRuns["test"].executionTask.configure { + enabled = false useJUnitPlatform() } // set up according to https://jakewharton.com/gradle-toolchains-are-rarely-a-good-idea/ @@ -129,10 +129,9 @@ tasks.withType { enabled = true } -@Suppress("OPT_IN_USAGE") powerAssert { functions = listOf( - "com.xemantic.anthropic.test.shouldBe" + "io.kotest.matchers.shouldBe" ) includedSourceSets = listOf("commonTest", "jvmTest", "nativeTest") } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c278730..41c3eb9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,7 +5,7 @@ javaTarget = "17" kotlin = "2.0.21" kotlinxCoroutines = "1.9.0" ktor = "3.0.0" -kotest = "5.9.1" +kotest = "6.0.0.M1" log4j = "2.24.1" jackson = "2.18.0" diff --git a/src/commonMain/kotlin/Anthropic.kt b/src/commonMain/kotlin/Anthropic.kt index d58966b..b4e48fb 100644 --- a/src/commonMain/kotlin/Anthropic.kt +++ b/src/commonMain/kotlin/Anthropic.kt @@ -8,6 +8,7 @@ import com.xemantic.anthropic.message.MessageResponse import com.xemantic.anthropic.message.Tool import com.xemantic.anthropic.message.ToolUse import com.xemantic.anthropic.tool.UsableTool +import com.xemantic.anthropic.tool.toolOf import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.plugins.contentnegotiation.ContentNegotiation @@ -73,9 +74,9 @@ fun Anthropic( defaultModel = defaultModel, directBrowserAccess = config.directBrowserAccess ).apply { - usableTools = config.usableTools + toolEntryMap = (config.usableTools as List>).associateBy { it.tool.name } } -} +} // TODO this can be a second constructor, then toolMap can be private class Anthropic internal constructor( val apiKey: String, @@ -93,36 +94,35 @@ class Anthropic internal constructor( var apiBase: String = ANTHROPIC_API_BASE var defaultModel: String? = null var directBrowserAccess: Boolean = false - var usableTools: List> = emptyList() + @PublishedApi + internal var usableTools: List> = emptyList() inline fun tool( - block: T.() -> Unit = {} + noinline block: T.() -> Unit = {} ) { - usableTools += T::class + val entry = ToolEntry(toolOf(), serializer(), block) + usableTools += entry } + } - private class ToolEntry( - val tool: Tool, + @PublishedApi + internal class ToolEntry( + val tool: Tool, // TODO, no cache control + val serializer: KSerializer, + val initializer: T.() -> Unit = {} ) - private var toolSerializerMap = mapOf>() + internal var toolEntryMap = mapOf>() - var usableTools: List> = emptyList() - get() = field - set(value) { - value.validate() - field = value - } +// var usableTools: List> = emptyList() +// set(value) { +// toolMap += mapOf(value) +// field = value +// } inline fun tool() { - usableTools += T::class - } - - fun List>.validate() { - forEach { tool -> - //tool.serializer() - } + //usableTools += T::class } private val client = HttpClient { @@ -146,14 +146,17 @@ class Anthropic internal constructor( } } - inner class Messages() { + inner class Messages { suspend fun create( block: MessageRequest.Builder.() -> Unit ): MessageResponse { + val request = MessageRequest.Builder( - defaultModel + defaultModel, + toolEntryMap = toolEntryMap ).apply(block).build() + val response = client.post("/v1/messages") { contentType(ContentType.Application.Json) setBody(request) @@ -161,7 +164,10 @@ class Anthropic internal constructor( if (response.status.isSuccess()) { return response.body().apply { content.filterIsInstance() - .forEach { it.toolSerializerMap = toolSerializerMap } + .forEach { toolUse -> + val entry = toolEntryMap[toolUse.name]!! + toolUse.toolEntry = entry + } } } else { throw AnthropicException( @@ -175,7 +181,10 @@ class Anthropic internal constructor( block: MessageRequest.Builder.() -> Unit ): Flow = flow { - val request = MessageRequest.Builder(defaultModel).apply { + val request = MessageRequest.Builder( + defaultModel, + toolEntryMap = toolEntryMap + ).apply { block(this) stream = true }.build() diff --git a/src/commonMain/kotlin/message/Messages.kt b/src/commonMain/kotlin/message/Messages.kt index d9868c4..a17224b 100644 --- a/src/commonMain/kotlin/message/Messages.kt +++ b/src/commonMain/kotlin/message/Messages.kt @@ -1,5 +1,6 @@ package com.xemantic.anthropic.message +import com.xemantic.anthropic.Anthropic import com.xemantic.anthropic.anthropicJson import com.xemantic.anthropic.schema.JsonSchema import com.xemantic.anthropic.tool.UsableTool @@ -48,8 +49,9 @@ data class MessageRequest( val topP: Int? ) { - class Builder( - val defaultApiModel: String + class Builder internal constructor( + private val defaultModel: String, + private val toolEntryMap: Map> ) { var model: String? = null var maxTokens = 1024 @@ -66,11 +68,7 @@ data class MessageRequest( val topP: Int? = null fun useTools() { - //too - } - - fun tools(vararg classes: KClass) { - // TODO it needs access to Anthropic, therefore either needs a constructor parameter, or needs to be inner class + tools = toolEntryMap.values.map { it.tool } } fun messages(vararg messages: Message) { @@ -96,7 +94,7 @@ data class MessageRequest( } fun build(): MessageRequest = MessageRequest( - model = if (model != null) model!! else defaultApiModel, + model = if (model != null) model!! else defaultModel, maxTokens = maxTokens, messages = messages, metadata = metadata, @@ -117,7 +115,9 @@ fun MessageRequest( defaultModel: String, block: MessageRequest.Builder.() -> Unit ): MessageRequest { - val builder = MessageRequest.Builder(defaultModel) + val builder = MessageRequest.Builder( + defaultModel, emptyMap() + ) block(builder) return builder.build() } @@ -286,11 +286,14 @@ data class ToolUse( ) : Content() { @Transient - internal lateinit var toolSerializerMap: Map> + internal lateinit var toolEntry: Anthropic.ToolEntry fun use(): ToolResult { - val serializer = toolSerializerMap[name]!! - val tool = anthropicJson.decodeFromJsonElement(serializer, input) + val tool = anthropicJson.decodeFromJsonElement( + deserializer = toolEntry.serializer, + element = input + ) + toolEntry.initializer(tool) return tool.use(toolUseId = id) } diff --git a/src/commonMain/kotlin/tool/Tools.kt b/src/commonMain/kotlin/tool/Tools.kt index aca6f39..0889d93 100644 --- a/src/commonMain/kotlin/tool/Tools.kt +++ b/src/commonMain/kotlin/tool/Tools.kt @@ -6,7 +6,6 @@ import com.xemantic.anthropic.message.Tool import com.xemantic.anthropic.message.ToolResult import com.xemantic.anthropic.message.ToolUse import com.xemantic.anthropic.schema.jsonSchemaOf -import com.xemantic.anthropic.schema.toJsonSchema import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.InternalSerializationApi import kotlinx.serialization.KSerializer @@ -17,6 +16,15 @@ import kotlinx.serialization.modules.polymorphic import kotlinx.serialization.serializer import kotlin.reflect.KClass +/** + * Annotation used to mark a class extending the [UsableTool]. + * + * This annotation provides metadata for tools that can be serialized and used in the context + * of the Anthropic API. It includes a name and description for the tool. + * + * @property name The name of the tool. This name is used during serialization and should be a unique identifier for the tool. + * @property description A comprehensive description of what the tool does and how it should be used. + */ @OptIn(ExperimentalSerializationApi::class) @MetaSerializable @Target(AnnotationTarget.CLASS) @@ -25,9 +33,21 @@ annotation class SerializableTool( val description: String ) -@OptIn(ExperimentalSerializationApi::class) +/** + * Interface for tools that can be used in the context of the Anthropic API. + * + * Classes implementing this interface represent tools that can be executed + * with a given tool use ID. The implementation of the [use] method should + * contain the logic for executing the tool and returning the [ToolResult]. + */ interface UsableTool { + /** + * Executes the tool and returns the result. + * + * @param toolUseId A unique identifier for this particular use of the tool. + * @return A [ToolResult] containing the outcome of executing the tool. + */ fun use( toolUseId: String ): ToolResult @@ -44,80 +64,30 @@ fun Tool.cacheControl( ) @OptIn(ExperimentalSerializationApi::class) -inline fun toolOf(): Tool { +inline fun toolOf( + cacheControl: CacheControl? = null // TODO should it be here? +): Tool { + val serializer = try { serializer() } catch (e :SerializationException) { - throw SerializationException("The class ${T::class.qualifiedName} must be annotated with @SerializableTool", e) - } - val description = checkNotNull( - serializer - .descriptor - .annotations - .filterIsInstance() - .firstOrNull() - ) { - "No @Description annotation found for ${T::class.qualifiedName}" + throw SerializationException( + "The class ${T::class.qualifiedName} must be annotated with @SerializableTool", e + ) } + + val serializableTool = serializer + .descriptor + .annotations + .filterIsInstance() + .firstOrNull() ?: throw SerializationException( + "The class ${T::class.qualifiedName} must be annotated with @SerializableTool", + ) + return Tool( - name = description.name, - description = description.description, + name = serializableTool.name, + description = serializableTool.description, inputSchema = jsonSchemaOf(), - cacheControl = null + cacheControl = cacheControl ) } - -//@OptIn(InternalSerializationApi::class, ExperimentalSerializationApi::class) -//fun KClass.verify() { -// // TODO how to get class serializer correctly? -// checkNotNull(serializer()) { -// "Invalid tool definition, not serializer for class ${this@verify}" -// } -// checkNotNull(serializer().descriptor.annotations.filterIsInstance().firstOrNull()) { -// "Not @Description annotation specified for the tool" -// } -//} - -//@OptIn(InternalSerializationApi::class, ExperimentalSerializationApi::class) -//fun KClass.instance( -// cacheControl: CacheControl? = null -//): Tool { -// val descriptor = serializer().descriptor -// val description = descriptor.annotations.filterIsInstance().firstOrNull()!!.value -// return Tool( -// name = descriptor.serialName, -// description = description, -// inputSchema = toJsonSchema(), -// cacheControl = cacheControl -// ) -//} - -//inline fun anthropicTypeOf(): String = -// T::class.qualifiedName!!.replace('.', '_') - - -@OptIn(InternalSerializationApi::class) -fun List>.toSerializersModule(): SerializersModule = SerializersModule { - polymorphic(UsableTool::class) { - forEach { subclass(it, it.serializer()) } - } -} - -//inline fun Tool( -// description: String, -// cacheControl: CacheControl? = null -//): Tool = Tool( -// name = anthropicTypeOf(), -// description = description, -// inputSchema = jsonSchemaOf(), -// cacheControl = cacheControl -//) - - -fun ToolUse.use( - map: Map> -): ToolResult { - val serializer = map[name]!! - val tool = anthropicJson.decodeFromJsonElement(serializer, input) - return tool.use(toolUseId = id) -} diff --git a/src/commonTest/kotlin/AnthropicTest.kt b/src/commonTest/kotlin/AnthropicTest.kt index 1927e4d..ffa6a5c 100644 --- a/src/commonTest/kotlin/AnthropicTest.kt +++ b/src/commonTest/kotlin/AnthropicTest.kt @@ -9,16 +9,17 @@ import com.xemantic.anthropic.message.Role import com.xemantic.anthropic.message.StopReason import com.xemantic.anthropic.message.Text import com.xemantic.anthropic.message.ToolUse -import com.xemantic.anthropic.test.then -import com.xemantic.anthropic.test.shouldBe +import io.kotest.assertions.assertSoftly +import io.kotest.matchers.ints.shouldBeGreaterThan +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain +import io.kotest.matchers.types.instanceOf import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.toList import kotlinx.coroutines.test.runTest import kotlin.test.Test -import kotlin.test.assertEquals import kotlin.test.assertFalse -import kotlin.test.assertNull import kotlin.test.assertTrue class AnthropicTest { @@ -38,18 +39,18 @@ class AnthropicTest { } // then - response.apply { - assertTrue(type == MessageResponse.Type.MESSAGE) - assertTrue(role == Role.ASSISTANT) - assertTrue(model == "claude-3-opus-20240229") - assertTrue(content.size == 1) - assertTrue(content[0] is Text) + assertSoftly(response) { + type shouldBe MessageResponse.Type.MESSAGE + role shouldBe Role.ASSISTANT + model shouldBe "claude-3-opus-20240229" + stopReason shouldBe StopReason.END_TURN + content.size shouldBe 1 + content[0] shouldBe instanceOf() val text = content[0] as Text - assertTrue(text.text.contains("Claude")) - assertTrue(stopReason == StopReason.END_TURN) - assertNull(stopSequence) - assertEquals(usage.inputTokens, 15) - assertTrue(usage.outputTokens > 0) + text.text shouldContain "Claude" + stopSequence shouldBe null + usage.inputTokens shouldBe 15 + usage.outputTokens shouldBeGreaterThan 0 } } @@ -72,11 +73,12 @@ class AnthropicTest { } // then - response.apply { - assertTrue(1 == content.size) - assertTrue(content[0] is Text) + assertSoftly(response) { + stopReason shouldBe StopReason.END_TURN + content.size shouldBe 1 + content[0] shouldBe instanceOf() val text = content[0] as Text - assertTrue(text.text.lowercase().contains("foo")) + text.text.uppercase() shouldContain "foo" } } @@ -98,34 +100,57 @@ class AnthropicTest { .joinToString(separator = "") // then - assertTrue(response == "The quick brown fox jumps over the lazy dog.") + response shouldBe "The quick brown fox jumps over the lazy dog." } @Test fun shouldUseCalculatorTool() = runTest { // given - val client = Anthropic() - client.usableTools += Calculator::class + val client = Anthropic { + tool() + } + val conversation = mutableListOf() + conversation += Message { + +"What's 15 multiplied by 7?" + } // when - val response = client.messages.create { - +Message { - +"What's 15 multiplied by 7?" - } - tools(Calculator::class) + val response1 = client.messages.create { + messages = conversation + useTools() } // then - response.apply { - assertTrue(content.size == 1) - assertTrue(content[0] is ToolUse) - val toolUse = content[0] as ToolUse - assertTrue(toolUse.name == "com_xemantic_anthropic_AnthropicTest_Calculator") - val result = toolUse.use() - assertTrue(result.toolUseId == toolUse.id) - assertFalse(result.isError) - assertTrue(result.content == listOf(Text(text = "${15.0 * 7.0}"))) + assertSoftly(response1) { + stopReason shouldBe StopReason.TOOL_USE + content.size shouldBe 2 + content[0] shouldBe instanceOf() + (content[0] as Text).text shouldContain "" + content[1] shouldBe instanceOf() + (content[1] as ToolUse).name shouldBe "Calculator" + } + + conversation += response1.asMessage() + + val toolUse = response1.content[1] as ToolUse + val result = toolUse.use() // here we execute the tool + + conversation += Message { +result } + + // when + val response2 = client.messages.create { + messages = conversation + useTools() } + + // then + assertSoftly(response2) { + stopReason shouldBe StopReason.END_TURN + content.size shouldBe 1 + content[0] shouldBe instanceOf() + (content[0] as Text).text shouldContain "105" + } + } @Test @@ -238,7 +263,8 @@ class AnthropicTest { useTools() // TODO it should be a single tool } - then(response1) { + // then + assertSoftly(response1) { stopReason shouldBe StopReason.TOOL_USE content.size shouldBe 1 content[0] shouldBe ToolUse @@ -247,7 +273,7 @@ class AnthropicTest { } val toolUse = response1.content[0] as ToolUse val result = toolUse.use() - then(result) { + assertSoftly(result) { toolUseId shouldBe toolUse isError shouldBe false content shouldBe listOf(Text(text = "267914296")) @@ -259,7 +285,7 @@ class AnthropicTest { messages = conversation } - then(response2) { + assertSoftly(response2) { content.size shouldBe 1 content[0] is Text val text = content[0] as Text diff --git a/src/commonTest/kotlin/message/MessagesTest.kt b/src/commonTest/kotlin/message/MessagesTest.kt index 43cab02..bdf3dd4 100644 --- a/src/commonTest/kotlin/message/MessagesTest.kt +++ b/src/commonTest/kotlin/message/MessagesTest.kt @@ -1,9 +1,10 @@ package com.xemantic.anthropic.message -import com.xemantic.anthropic.Calculator import com.xemantic.anthropic.anthropicJson -import com.xemantic.anthropic.tool.toSerializersModule +import io.kotest.assertions.assertSoftly import io.kotest.assertions.json.shouldEqualJson +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.instanceOf import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json @@ -21,7 +22,14 @@ class MessagesTest { prettyPrint = true @OptIn(ExperimentalSerializationApi::class) prettyPrintIndent = " " - //serializersModule = testToolsSerializersModule + } + + @Test + fun defaultMessageShouldHaveRoleUser() { + // given + val message = Message {} + // then + message.role shouldBe Role.USER } @Test @@ -59,11 +67,8 @@ class MessagesTest { } @Test - fun shouldDeserializeToolUseRequest() { - val json = Json(from = json) { - serializersModule = listOf(Calculator::class).toSerializersModule() - } - val request = """ + fun shouldDeserializeToolUseMessageResponseAndUseTool() { + val jsonResponse = """ { "id": "msg_01PspkNzNG3nrf5upeTsmWLF", "type": "message", @@ -73,7 +78,7 @@ class MessagesTest { { "type": "tool_use", "id": "toolu_01YHJK38TBKCRPn7zfjxcKHx", - "name": "com_xemantic_anthropic_AnthropicTest_Calculator", + "name": "Calculator", "input": { "operation": "MULTIPLY", "a": 15, @@ -90,7 +95,25 @@ class MessagesTest { } """.trimIndent() - val response = json.decodeFromString(request) + val response = json.decodeFromString(jsonResponse) + assertSoftly(response) { + content.size shouldBe 1 + content[0] shouldBe instanceOf() + } +// val toolUse = response.content[0] as ToolUse +// toolUse.toolEntry = Anthropic.ToolEntry( +// tool = toolOf(), +// +// )toolSerializerMap = mapOf("Calculator" to serializer()) +// val toolResult = toolUse.use() +// assertSoftly(toolResult) { +// toolUseId shouldBe "toolu_01YHJK38TBKCRPn7zfjxcKHx" +// isError shouldBe false +// cacheControl shouldBe null +// content.size shouldBe 1 +// content[0] shouldBe instanceOf() +// (content[0] as Text).text shouldBe "105.0" +// } } } diff --git a/src/commonTest/kotlin/test/AnthropicTest.kt b/src/commonTest/kotlin/test/AnthropicTest.kt deleted file mode 100644 index a698d5e..0000000 --- a/src/commonTest/kotlin/test/AnthropicTest.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.xemantic.anthropic.test - -import kotlin.reflect.KClass -import kotlin.test.assertTrue - -fun then(value: T, block: T.() -> Unit) { - block(value) -} - -infix fun T.shouldBe(expected: T): Unit = assert(expected == this) - -fun T.shouldBe(expected: T, message: () -> String): Unit = assert(this == expected, message) - -//infix fun T.shouldBe(expected: KClass) { -// assert(this is expected) -//} diff --git a/src/commonTest/kotlin/tool/UsableToolTest.kt b/src/commonTest/kotlin/tool/UsableToolTest.kt index b1b4d67..eb0c3b9 100644 --- a/src/commonTest/kotlin/tool/UsableToolTest.kt +++ b/src/commonTest/kotlin/tool/UsableToolTest.kt @@ -1,14 +1,15 @@ package com.xemantic.anthropic.tool +import com.xemantic.anthropic.message.CacheControl import com.xemantic.anthropic.message.ToolResult import com.xemantic.anthropic.schema.JsonSchema import com.xemantic.anthropic.schema.JsonSchemaProperty -import com.xemantic.anthropic.test.then -import com.xemantic.anthropic.test.shouldBe +import io.kotest.assertions.assertSoftly +import io.kotest.assertions.throwables.shouldThrowWithMessage +import io.kotest.matchers.shouldBe import kotlinx.serialization.Serializable import kotlinx.serialization.SerializationException import kotlin.test.Test -import kotlin.test.assertFailsWith class UsableToolTest { @@ -27,7 +28,7 @@ class UsableToolTest { // when val tool = toolOf() - then(tool) { + assertSoftly(tool) { name shouldBe "TestTool" description shouldBe "Test tool receiving a message and outputting it back" inputSchema shouldBe JsonSchema( @@ -38,20 +39,35 @@ class UsableToolTest { } } + @Test + fun shouldCreateToolWithCacheControlFromUsableTool() { + // when + val tool = toolOf( + cacheControl = CacheControl(type = CacheControl.Type.EPHEMERAL) + ) + + assertSoftly(tool) { + name shouldBe "TestTool" + description shouldBe "Test tool receiving a message and outputting it back" + inputSchema shouldBe JsonSchema( + properties = mapOf("message" to JsonSchemaProperty.STRING), + required = listOf("message") + ) + cacheControl shouldBe CacheControl(type = CacheControl.Type.EPHEMERAL) + } + } + class NoAnnotationTool : UsableTool { override fun use(toolUseId: String) = ToolResult(toolUseId, "nothing") } @Test fun shouldFailToCreateToolWithoutSerializableToolAnnotation() { - assertFailsWith { + shouldThrowWithMessage( + "The class com.xemantic.anthropic.tool.UsableToolTest.NoAnnotationTool must be annotated with @SerializableTool" + ) { toolOf() } - try { - toolOf() - } catch (e: SerializationException) { - e.message shouldBe "The class com.xemantic.anthropic.tool.UsableToolTest.NoAnnotationTool must be annotated with @SerializableTool" - } } @Serializable @@ -61,13 +77,10 @@ class UsableToolTest { @Test fun shouldFailToCreateToolWithOnlySerializableToolAnnotation() { - assertFailsWith { - toolOf() - } - try { + shouldThrowWithMessage( + "The class com.xemantic.anthropic.tool.UsableToolTest.OnlySerializableAnnotationTool must be annotated with @SerializableTool" + ) { toolOf() - } catch (e: SerializationException) { - e.message shouldBe "The class com.xemantic.anthropic.tool.UsableToolTest.NoAnnotationTool must be annotated with @SerializableTool" } }