Skip to content

Commit 3d1fd3d

Browse files
authored
Add extension point for custom tool schemas (#1158)
<!-- Thank you for opening a pull request! Please add a brief description of the proposed change here. Also, please tick the appropriate points in the checklist below. --> ## Motivation and Context The aim of this PR is to introduce extension point for clients to give ability to provide custom tool schema / add fields for existing schemas. It might be useful for example in cases when you have almost OpenAI compatible API, but with slight differences in tool schema. ## Breaking Changes <!-- Will users need to update their code or configurations? --> No --- #### Type of the changes - [x] New feature (non-breaking change which adds functionality) - [ ] Bug fix (non-breaking change which fixes an issue) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Tests improvement - [ ] Refactoring #### Checklist - [x] The pull request has a description of the proposed change - [x] I read the [Contributing Guidelines](https://github.com/JetBrains/koog/blob/main/CONTRIBUTING.md) before opening the pull request - [x] The pull request uses **`develop`** as the base branch - [x] Tests for the changes have been added - [x] All new and existing tests passed ##### Additional steps for pull requests adding a new feature - [ ] An issue describing the proposed change exists - [ ] The pull request includes a link to the issue - [ ] The change was discussed and approved in the issue - [x] Docs have been added / updated
1 parent 9f37da5 commit 3d1fd3d

File tree

23 files changed

+832
-429
lines changed

23 files changed

+832
-429
lines changed

agents/agents-features/agents-features-event-handler/src/jvmTest/kotlin/ai/koog/agents/features/eventHandler/feature/EventHandlerTest.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import ai.koog.agents.core.dsl.extension.nodeLLMSendToolResult
1111
import ai.koog.agents.core.dsl.extension.onAssistantMessage
1212
import ai.koog.agents.core.dsl.extension.onToolCall
1313
import ai.koog.agents.core.feature.handler.subgraph.SubgraphExecutionEventContext
14+
import ai.koog.agents.core.tools.ToolDescriptor
1415
import ai.koog.agents.core.tools.ToolRegistry
1516
import ai.koog.agents.features.eventHandler.eventString
1617
import ai.koog.agents.testing.tools.DummyTool
@@ -577,13 +578,13 @@ class EventHandlerTest {
577578
override suspend fun execute(
578579
prompt: Prompt,
579580
model: ai.koog.prompt.llm.LLModel,
580-
tools: List<ai.koog.agents.core.tools.ToolDescriptor>
581+
tools: List<ToolDescriptor>
581582
): List<Message.Response> = emptyList()
582583

583584
override fun executeStreaming(
584585
prompt: Prompt,
585586
model: ai.koog.prompt.llm.LLModel,
586-
tools: List<ai.koog.agents.core.tools.ToolDescriptor>
587+
tools: List<ToolDescriptor>
587588
): Flow<StreamFrame> = flow {
588589
throw IllegalStateException(testStreamingErrorMessage)
589590
}

agents/agents-features/agents-features-trace/src/jvmTest/kotlin/ai/koog/agents/features/tracing/writer/TraceFeatureMessageTestWriterTest.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import ai.koog.agents.core.feature.model.events.SubgraphExecutionFailedEvent
1919
import ai.koog.agents.core.feature.model.events.SubgraphExecutionStartingEvent
2020
import ai.koog.agents.core.feature.model.events.ToolCallCompletedEvent
2121
import ai.koog.agents.core.feature.model.events.ToolCallStartingEvent
22+
import ai.koog.agents.core.tools.ToolDescriptor
2223
import ai.koog.agents.core.tools.ToolRegistry
2324
import ai.koog.agents.core.utils.SerializationUtils
2425
import ai.koog.agents.features.tracing.feature.Tracing
@@ -443,13 +444,13 @@ class TraceFeatureMessageTestWriterTest {
443444
override suspend fun execute(
444445
prompt: Prompt,
445446
model: ai.koog.prompt.llm.LLModel,
446-
tools: List<ai.koog.agents.core.tools.ToolDescriptor>
447+
tools: List<ToolDescriptor>
447448
): List<Message.Response> = emptyList()
448449

449450
override fun executeStreaming(
450451
prompt: Prompt,
451452
model: ai.koog.prompt.llm.LLModel,
452-
tools: List<ai.koog.agents.core.tools.ToolDescriptor>
453+
tools: List<ToolDescriptor>
453454
): Flow<StreamFrame> = flow {
454455
val testException = IllegalStateException(testStreamingErrorMessage)
455456
testStreamingStackTrace = testException.stackTraceToString()
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package ai.koog.agents.core.tools
2+
3+
/**
4+
* Represents a descriptor for a tool that contains information about the tool's name, description, required parameters,
5+
* and optional parameters.
6+
*
7+
* This class is annotated with @Serializable to support serialization/deserialization using kotlinx.serialization.
8+
*
9+
* @property name The name of the tool.
10+
* @property description The description of the tool.
11+
* @property requiredParameters A list of ToolParameterDescriptor representing the required parameters for the tool.
12+
* @property optionalParameters A list of ToolParameterDescriptor representing the optional parameters for the tool.
13+
*/
14+
public open class ToolDescriptor(
15+
public val name: String,
16+
public val description: String,
17+
public val requiredParameters: List<ToolParameterDescriptor> = emptyList(),
18+
public val optionalParameters: List<ToolParameterDescriptor> = emptyList(),
19+
) {
20+
/**
21+
* Creates a copy of the current ToolDescriptor with the option to modify specific attributes.
22+
*
23+
* @param name The name of the tool. Defaults to the current tool's name if not provided.
24+
* @param description The description of the tool. Defaults to the current tool's description if not provided.
25+
* @param requiredParameters A list of ToolParameterDescriptor representing the required parameters for the tool.
26+
* Defaults to the current required parameters if not provided.
27+
* @param optionalParameters A list of ToolParameterDescriptor representing the optional parameters for the tool.
28+
* Defaults to the current optional parameters if not provided.
29+
* @return A new instance of ToolDescriptor with the updated attributes.
30+
*/
31+
public fun copy(
32+
name: String = this.name,
33+
description: String = this.description,
34+
requiredParameters: List<ToolParameterDescriptor> = this.requiredParameters.toList(),
35+
optionalParameters: List<ToolParameterDescriptor> = this.optionalParameters.toList(),
36+
): ToolDescriptor {
37+
return ToolDescriptor(
38+
name = name,
39+
description = description,
40+
requiredParameters = requiredParameters,
41+
optionalParameters = optionalParameters,
42+
)
43+
}
44+
45+
override fun equals(other: Any?): Boolean {
46+
if (this === other) return true
47+
if (other !is ToolDescriptor) return false
48+
49+
if (name != other.name) return false
50+
if (description != other.description) return false
51+
if (requiredParameters != other.requiredParameters) return false
52+
if (optionalParameters != other.optionalParameters) return false
53+
54+
return true
55+
}
56+
57+
override fun toString(): String {
58+
return "ToolDescriptor(name=$name, description=$description, requiredParameters=$requiredParameters, optionalParameters=$optionalParameters)"
59+
}
60+
61+
override fun hashCode(): Int {
62+
var result = name.hashCode()
63+
result = 31 * result + description.hashCode()
64+
result = 31 * result + requiredParameters.hashCode()
65+
result = 31 * result + optionalParameters.hashCode()
66+
return result
67+
}
68+
}

agents/agents-tools/src/commonMain/kotlin/ai/koog/agents/core/tools/ToolDescriptors.kt

Lines changed: 0 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,24 +2,6 @@ package ai.koog.agents.core.tools
22

33
import kotlin.enums.EnumEntries
44

5-
/**
6-
* Represents a descriptor for a tool that contains information about the tool's name, description, required parameters,
7-
* and optional parameters.
8-
*
9-
* This class is annotated with @Serializable to support serialization/deserialization using kotlinx.serialization.
10-
*
11-
* @property name The name of the tool.
12-
* @property description The description of the tool.
13-
* @property requiredParameters A list of ToolParameterDescriptor representing the required parameters for the tool.
14-
* @property optionalParameters A list of ToolParameterDescriptor representing the optional parameters for the tool.
15-
*/
16-
public data class ToolDescriptor(
17-
val name: String,
18-
val description: String,
19-
val requiredParameters: List<ToolParameterDescriptor> = emptyList(),
20-
val optionalParameters: List<ToolParameterDescriptor> = emptyList(),
21-
)
22-
235
/**
246
* Represents a descriptor for a tool parameter.
257
* A tool parameter descriptor contains information about a specific tool parameter, such as its name, description,
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package ai.koog.agents.core.tools.serialization
2+
3+
import ai.koog.agents.core.tools.ToolDescriptor
4+
import kotlinx.serialization.json.JsonObject
5+
6+
/**
7+
* Interface for converting a list of ToolDescriptor objects to a JSON schema representation.
8+
*/
9+
public interface ToolDescriptorSchemaGenerator {
10+
/**
11+
* Converts a list of ToolDescriptor objects into a JSON object representation.
12+
*
13+
* @param toolDescriptor The ToolDescriptor to convert.
14+
* @return A JsonObject containing the JSON representation of the provided list of ToolDescriptor objects.
15+
*/
16+
public fun generate(toolDescriptor: ToolDescriptor): JsonObject
17+
}

agents/agents-tools/src/commonTest/kotlin/ai/koog/agents/core/tools/SerialToToolDescriptionTest.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -272,10 +272,10 @@ class SerialToToolDescriptionTest {
272272
fun verify_optional_description_applies() {
273273
val tripPlanDescriptor = serializer<TripPlan>().descriptor.asToolDescriptor(
274274
toolName = "provideTripPlan",
275-
toolDescription = "Custom tool ,call me!"
275+
toolDescription = "Custom tool, call me!"
276276
)
277277

278-
assertEquals("Custom tool ,call me!", tripPlanDescriptor.description)
279-
assertEquals(expectedTripPlanToolDescriptor.copy(description = "Custom tool ,call me!"), tripPlanDescriptor)
278+
assertEquals("Custom tool, call me!", tripPlanDescriptor.description)
279+
assertEquals(expectedTripPlanToolDescriptor.copy(description = "Custom tool, call me!"), tripPlanDescriptor)
280280
}
281281
}
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
# ToolDescriptorSchemer
2+
3+
## What is it
4+
5+
`ToolDescriptorSchemer` is a extension point that converts a `ToolDescriptor` into a JSON Schema object compatible with specific LLM providers.
6+
7+
Key points:
8+
9+
- Location: `ai.koog.agents.core.tools.serialization.ToolDescriptorSchemer`
10+
- Contract: a single function `scheme(toolDescriptor: ToolDescriptor): JsonObject`
11+
- Implementations provided:
12+
- `OpenAICompatibleToolDescriptorSchemer` — generates schemas compatible with OpenAI‑style function/tool definitions.
13+
- `OllamaToolDescriptorSchemer` — generates schemas compatible with Ollama tool JSON.
14+
15+
16+
<!--- INCLUDE
17+
import ai.koog.agents.core.tools.ToolDescriptor
18+
import ai.koog.agents.core.tools.ToolParameterDescriptor
19+
import ai.koog.agents.core.tools.ToolParameterType
20+
import kotlinx.serialization.json.JsonObject
21+
-->
22+
```kotlin
23+
// Interface
24+
interface ToolDescriptorSchemaGenerator {
25+
fun generate(toolDescriptor: ToolDescriptor): JsonObject
26+
}
27+
```
28+
<!--- KNIT example-tool-descriptor-schemer-01.kt -->
29+
30+
## Why to use it?
31+
If you want to provide custom scheme for existing or new LLM providers, implement this interface to convert Koog’s `ToolDescriptor` into the expected JSON Schema format.
32+
33+
## Implementation example
34+
35+
Below is a minimal custom implementation that renders only a subset of parameter types to illustrate how to plug into the SPI. Real implementations should cover all `ToolParameterType`s (String, Integer, Float, Boolean, Null, Enum, List, Object, AnyOf).
36+
37+
<!--- INCLUDE
38+
import ai.koog.agents.core.tools.ToolDescriptor
39+
import ai.koog.agents.core.tools.ToolParameterDescriptor
40+
import ai.koog.agents.core.tools.ToolParameterType
41+
import ai.koog.agents.core.tools.serialization.ToolDescriptorSchemaGenerator
42+
import kotlinx.serialization.json.JsonPrimitive
43+
import kotlinx.serialization.json.JsonObject
44+
import kotlinx.serialization.json.buildJsonObject
45+
import kotlinx.serialization.json.put
46+
import kotlinx.serialization.json.putJsonArray
47+
import kotlinx.serialization.json.putJsonObject
48+
-->
49+
```kotlin
50+
51+
class MinimalSchemer : ToolDescriptorSchemaGenerator {
52+
override fun generate(toolDescriptor: ToolDescriptor): JsonObject = buildJsonObject {
53+
put("type", "object")
54+
putJsonObject("properties") {
55+
(toolDescriptor.requiredParameters + toolDescriptor.optionalParameters).forEach { p ->
56+
put(p.name, buildJsonObject {
57+
put("description", p.description)
58+
when (val t = p.type) {
59+
ToolParameterType.String -> put("type", "string")
60+
ToolParameterType.Integer -> put("type", "integer")
61+
is ToolParameterType.Enum -> {
62+
put("type", "string")
63+
putJsonArray("enum") { t.entries.forEach { add(JsonPrimitive(it)) } }
64+
}
65+
else -> put("type", "string") // fallback for brevity
66+
}
67+
})
68+
}
69+
}
70+
putJsonArray("required") { toolDescriptor.requiredParameters.forEach { add(JsonPrimitive(it.name)) } }
71+
}
72+
}
73+
```
74+
<!--- KNIT example-tool-descriptor-schemer-02.kt -->
75+
76+
## Example of usage with client
77+
78+
Typically you do not need to call a schemer directly. Koog clients accept a list of `ToolDescriptor` objects and apply the correct schemer internally when serializing requests for the provider.
79+
80+
The example below defines a simple tool and passes it to the OpenAI client. The client will use `OpenAICompatibleToolDescriptorSchemer` under the hood to build the JSON schema.
81+
82+
<!--- INCLUDE
83+
import ai.koog.prompt.executor.clients.openai.OpenAILLMClient
84+
import ai.koog.agents.core.tools.ToolDescriptor
85+
import ai.koog.agents.core.tools.ToolParameterDescriptor
86+
import ai.koog.agents.core.tools.ToolParameterType
87+
import ai.koog.prompt.dsl.Prompt
88+
import ai.koog.prompt.executor.clients.openai.OpenAIModels
89+
import ai.koog.prompt.executor.clients.openai.base.OpenAICompatibleToolDescriptorSchemaGenerator
90+
import kotlinx.serialization.json.JsonPrimitive
91+
import kotlinx.serialization.json.JsonObject
92+
import kotlinx.serialization.json.buildJsonObject
93+
import kotlinx.serialization.json.put
94+
import kotlinx.serialization.json.putJsonArray
95+
import kotlinx.serialization.json.putJsonObject
96+
import kotlinx.coroutines.runBlocking
97+
98+
class MinimalSchemer : OpenAICompatibleToolDescriptorSchemaGenerator() {
99+
override fun generate(toolDescriptor: ToolDescriptor): JsonObject = buildJsonObject {
100+
put("type", "object")
101+
putJsonObject("properties") {
102+
(toolDescriptor.requiredParameters + toolDescriptor.optionalParameters).forEach { p ->
103+
put(p.name, buildJsonObject {
104+
put("description", p.description)
105+
when (val t = p.type) {
106+
ToolParameterType.String -> put("type", "string")
107+
ToolParameterType.Integer -> put("type", "integer")
108+
is ToolParameterType.Enum -> {
109+
put("type", "string")
110+
putJsonArray("enum") { t.entries.forEach { add(JsonPrimitive(it)) } }
111+
}
112+
else -> put("type", "string") // fallback for brevity
113+
}
114+
})
115+
}
116+
}
117+
putJsonArray("required") { toolDescriptor.requiredParameters.forEach { add(JsonPrimitive(it.name)) } }
118+
}
119+
}
120+
121+
-->
122+
```kotlin
123+
val client = OpenAILLMClient(apiKey = System.getenv("OPENAI_API_KEY"), toolsConverter = MinimalSchemer())
124+
125+
val getUserTool = ToolDescriptor(
126+
name = "get_user",
127+
description = "Returns user profile by id",
128+
requiredParameters = listOf(
129+
ToolParameterDescriptor(
130+
name = "id",
131+
description = "User id",
132+
type = ToolParameterType.String
133+
)
134+
)
135+
)
136+
137+
val prompt = Prompt.build(id = "p1") { user("Hello") }
138+
val responses = runBlocking {
139+
client.execute(
140+
prompt = prompt,
141+
model = OpenAIModels.Chat.GPT4o,
142+
tools = listOf(getUserTool)
143+
)
144+
}
145+
```
146+
<!--- KNIT example-tool-descriptor-schemer-03.kt -->
147+
148+
If you need direct access to the produced schema (for debugging or for a custom transport), you can instantiate the provider‑specific schemer and serialize the JSON yourself:
149+
150+
<!--- INCLUDE
151+
import kotlinx.serialization.json.Json
152+
import ai.koog.prompt.executor.clients.openai.base.OpenAICompatibleToolDescriptorSchemaGenerator
153+
import ai.koog.agents.core.tools.ToolDescriptor
154+
import ai.koog.agents.core.tools.ToolParameterDescriptor
155+
import ai.koog.agents.core.tools.ToolParameterType
156+
157+
fun getUserTool(): ToolDescriptor {
158+
return ToolDescriptor(
159+
name = "get_user",
160+
description = "Returns user profile by id",
161+
requiredParameters = listOf(
162+
ToolParameterDescriptor(
163+
name = "id",
164+
description = "User id",
165+
type = ToolParameterType.String
166+
)
167+
)
168+
)
169+
}
170+
-->
171+
172+
```kotlin
173+
val json = Json { prettyPrint = true }
174+
val schema = OpenAICompatibleToolDescriptorSchemaGenerator().generate(getUserTool())
175+
```
176+
<!--- KNIT example-tool-descriptor-schemer-04.kt -->

docs/mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ nav:
1919
- Built-in tools: built-in-tools.md
2020
- Annotation-based tools: annotation-based-tools.md
2121
- Class-based tools: class-based-tools.md
22+
- Custom tools schema: tools/tool-descriptor-schemer.md
2223
- Events: agent-events.md
2324
- Strategies:
2425
- Pre-defined nodes and components: nodes-and-components.md

0 commit comments

Comments
 (0)