Skip to content

Commit 5ead93b

Browse files
Skobeltsynclaude
andcommitted
feat(#1015): tool(...) builders return typed Tool<Args, Result> handles
Add a Tool<Args, Result> @JvmInline value class wrapping the underlying ToolDef. Every tool(...) overload in ToolsBuilder now returns a typed handle: untyped overloads as Tool<Map<String, Any?>, Any?>, the typed inline overload as Tool<Args, Result>. Strictly additive — return value is meaningless on its own and existing call sites continue to compile and behave identically. The handle is the foundation for typed Skill.tools(...) / +autoTool(...) overloads landing in #1016 (KSP P1.2), the first user-visible step of the KSP initiative described in docs/ksp-design.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c95ee6d commit 5ead93b

2 files changed

Lines changed: 119 additions & 6 deletions

File tree

src/main/kotlin/agents_engine/model/ToolDef.kt

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,27 @@ class ToolDef(
2828
internal set
2929
}
3030

31+
/**
32+
* Typed handle returned by every `tool(...)` builder overload. Wraps a
33+
* [ToolDef] with phantom type parameters that let `Skill.tools(...)` and
34+
* `+autoTool(...)` accept compile-time-checked references instead of
35+
* stringly-typed lookups (#1015 — KSP P1.1).
36+
*
37+
* `Args` is the deserialized input type for typed tools (the `@Generable`
38+
* data class), `Map<String, Any?>` for untyped tools. `Result` is the lambda's
39+
* return type. Both type parameters are erased at runtime — the [def]
40+
* underneath is the canonical runtime representation.
41+
*/
42+
@JvmInline
43+
value class Tool<Args, Result> @PublishedApi internal constructor(
44+
@PublishedApi internal val def: ToolDef,
45+
) {
46+
val name: String get() = def.name
47+
val description: String get() = def.description
48+
49+
override fun toString(): String = "Tool<${def.name}>"
50+
}
51+
3152
class ToolDefaultsBuilder {
3253
internal var errorHandler: ToolErrorHandler? = null
3354

@@ -64,21 +85,27 @@ class ToolsBuilder {
6485
defaultErrorHandler = builder.errorHandler
6586
}
6687

67-
fun tool(name: String, description: String, executor: (Map<String, Any?>) -> Any?) {
88+
fun tool(
89+
name: String,
90+
description: String,
91+
executor: (Map<String, Any?>) -> Any?,
92+
): Tool<Map<String, Any?>, Any?> {
6893
requireUserNotReservedToolName(name)
6994
require(defs.none { it.name == name }) {
7095
"Tool \"$name\" is already defined in this tools block. " +
7196
"Tool names must be unique."
7297
}
73-
defs.add(ToolDef(name = name, description = description, executor = executor))
98+
val def = ToolDef(name = name, description = description, executor = executor)
99+
defs.add(def)
100+
return Tool(def)
74101
}
75102

76103
fun tool(
77104
name: String,
78105
description: String,
79106
onError: OnErrorBuilder.() -> Unit,
80107
executor: (Map<String, Any?>) -> Any?,
81-
) {
108+
): Tool<Map<String, Any?>, Any?> {
82109
requireUserNotReservedToolName(name)
83110
require(defs.none { it.name == name }) {
84111
"Tool \"$name\" is already defined in this tools block. " +
@@ -87,9 +114,10 @@ class ToolsBuilder {
87114
val def = ToolDef(name = name, description = description, executor = executor)
88115
def.errorHandler = OnErrorBuilder().apply(onError).build()
89116
defs.add(def)
117+
return Tool(def)
90118
}
91119

92-
fun tool(name: String, block: ToolDefBuilder.() -> Unit) {
120+
fun tool(name: String, block: ToolDefBuilder.() -> Unit): Tool<Map<String, Any?>, Any?> {
93121
requireUserNotReservedToolName(name)
94122
require(defs.none { it.name == name }) {
95123
"Tool \"$name\" is already defined in this tools block. " +
@@ -99,6 +127,7 @@ class ToolsBuilder {
99127
builder.block()
100128
val def = builder.build()
101129
defs.add(def)
130+
return Tool(def)
102131
}
103132

104133
operator fun ToolDef.unaryPlus() {
@@ -127,7 +156,7 @@ class ToolsBuilder {
127156
name: String,
128157
description: String,
129158
crossinline executor: (Args) -> Result,
130-
) {
159+
): Tool<Args, Result> {
131160
requireUserNotReservedToolName(name)
132161
require(defs.none { it.name == name }) {
133162
"Tool \"$name\" is already defined in this tools block. " +
@@ -150,7 +179,9 @@ class ToolsBuilder {
150179
)
151180
executor(typed)
152181
}
153-
defs.add(ToolDef(name = name, description = description, executor = wrapped, argsType = argsClass))
182+
val def = ToolDef(name = name, description = description, executor = wrapped, argsType = argsClass)
183+
defs.add(def)
184+
return Tool(def)
154185
}
155186
}
156187

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
package agents_engine.model
2+
3+
import agents_engine.core.agent
4+
import agents_engine.generation.Generable
5+
import kotlin.test.Test
6+
import kotlin.test.assertEquals
7+
import kotlin.test.assertSame
8+
9+
/**
10+
* #1015 — `tool(...)` builders return typed `Tool<Args, Result>` handles.
11+
*
12+
* The handle is the basis for typed `Skill.tools(...)` / `+autoTool(...)` overloads
13+
* landing in #1016, but it must already work as a standalone return value in 0.2.x:
14+
* existing call sites that ignore the return value continue to compile, and new
15+
* call sites that bind the handle to a `val` see the type parameters propagate.
16+
*/
17+
class ToolHandleTest {
18+
19+
@Generable("a typed tool's args")
20+
data class FetchArgs(val url: String, val timeoutMs: Int = 5_000)
21+
22+
@Test
23+
fun `untyped tool builder returns Tool with Map Args`() {
24+
var captured: Tool<Map<String, Any?>, Any?>? = null
25+
agent<String, String>("untyped-tool-handle") {
26+
tools {
27+
captured = tool("fetch", "Fetch a URL") { args -> args["url"]?.toString() ?: "missing" }
28+
}
29+
skills { skill<String, String>("s") { implementedBy { it } } }
30+
}
31+
val handle = checkNotNull(captured)
32+
assertEquals("fetch", handle.name)
33+
assertEquals("Fetch a URL", handle.description)
34+
assertEquals("Tool<fetch>", handle.toString())
35+
}
36+
37+
@Test
38+
fun `typed tool builder returns Tool with reified Args`() {
39+
var captured: Tool<FetchArgs, String>? = null
40+
agent<String, String>("typed-tool-handle") {
41+
tools {
42+
captured = tool<FetchArgs, String>("fetch_typed", "Fetch typed") { args ->
43+
"GET ${args.url} (${args.timeoutMs}ms)"
44+
}
45+
}
46+
skills { skill<String, String>("s") { implementedBy { it } } }
47+
}
48+
val handle = checkNotNull(captured)
49+
assertEquals("fetch_typed", handle.name)
50+
assertEquals("Tool<fetch_typed>", handle.toString())
51+
}
52+
53+
@Test
54+
fun `block-builder tool returns Tool handle`() {
55+
var captured: Tool<Map<String, Any?>, Any?>? = null
56+
agent<String, String>("block-tool-handle") {
57+
tools {
58+
captured = tool("audit") {
59+
description("Audit log writer")
60+
executor { _ -> "logged" }
61+
}
62+
}
63+
skills { skill<String, String>("s") { implementedBy { it } } }
64+
}
65+
val handle = checkNotNull(captured)
66+
assertEquals("audit", handle.name)
67+
assertEquals("Audit log writer", handle.description)
68+
}
69+
70+
@Test
71+
fun `Tool def points at the same ToolDef registered with the agent`() {
72+
var captured: Tool<Map<String, Any?>, Any?>? = null
73+
val a = agent<String, String>("ref-identity") {
74+
tools {
75+
captured = tool("ping", "ping") { _ -> "pong" }
76+
}
77+
skills { skill<String, String>("s") { implementedBy { it } } }
78+
}
79+
val handle = checkNotNull(captured)
80+
assertSame(a.toolMap["ping"], handle.def)
81+
}
82+
}

0 commit comments

Comments
 (0)