跳到主要内容

工具系统:定义、schema 生成、注册与安全执行

30 秒导读: 工具是 agent 的手脚——让 LLM 不只是「说」,还能「做」(读文件、查数据库、调 API)。本章讲一条完整链路:一段普通 Kotlin 函数怎样变成工具、怎样把它的参数契约翻译成 LLM 能读的 JSON schema、怎样注册进表、以及被调用时怎样经过安全管线执行而不是被直接 call。

本章只讲工具本身。「LLM 在某个节点该选哪个工具」是图的 edge 谓词与 ToolSelectionStrategy 的事,见 01-graph-engine.md02-dsl-and-strategies.md。「工具描述最终怎么塞进 prompt、发给哪个 Client」见 03-llm-layer.md。「工具执行时被 feature 拦截、观测」见 05-features-runtime-extensions.md


1. 这是什么(零基础也能懂)

一句话定义: 工具(Tool)是一段带类型化输入/输出、并向 LLM 自我描述的可执行逻辑。LLM 读到它的名字和参数说明后,可以决定「调用它、传这些参数」;框架接住这个决定,真正把逻辑跑起来,再把结果喂回 LLM。

解决什么问题 / 给谁用: 纯 LLM 只会生成文本。你想让它「查一下今天订单数」「把这段文字情感打个分」「在数据库里建一条记录」——这些动作模型自己做不了,得有人替它做。工具就是那个「替它做」的东西。给谁用:写 agent 的 Kotlin/Java 工程师。

它能做什么:

  • 把任意 Kotlin/Java 函数零样板变成工具(@Tool 注解 + 反射)。
  • 从参数类型自动生成 LLM 能读的参数 schema。
  • 用一张注册表统一管理、按名字/类型查找工具。
  • 执行时走一条受控管线:类型解码 → 执行 → 结果编码 → 事件通知 feature,任何一步出错都被转成结构化的失败结果而不是崩掉。
  • 接入外部 MCP server 的工具,当成本地工具一样用。

用起来什么样: 最直接的一种——继承 SimpleTool,写一个返回字符串的工具:

// 示意,非源码:一个把文本情感打标签的工具
object ToneTool : SimpleTool<ToneTool.Args>(
argsType = typeToken<Args>(),
name = "analyze_tone",
description = "分析给定文本的情感倾向" // 这句会被 LLM 读到
) {
@Serializable
data class Args(val text: String) // 参数类型 → 自动变成 schema

override suspend fun doExecute(args: Args): String =
if ("糟糕" in args.text) "negative" else "positive"
}

val registry = ToolRegistry { tool(ToneTool) } // 注册,交给 agent

一句话直觉: 把工具想成招聘启事 + 岗位本人。招聘启事(ToolDescriptor)贴给 LLM 看,写清「岗位叫什么、需要哪些参数」;岗位本人(execute)在后台真正干活。LLM 只看启事做决定,永远不直接碰本人——中间隔着一层 HR(environment 管线)。


2. 顶层全景(它大概怎么转)

一个工具的一生分两大阶段:创作期(把它定义好、暴露给 LLM)和运行期(LLM 决定调用、框架执行)。

怎么读这张图: 从左到右是时间。上半行是创作期(编译/组装时做),下半行是运行期(每次调用时做)。竖线是「LLM」这道墙——墙左边是给模型看的描述,墙右边是真正的执行

创作期(定义 + 暴露)
你的类型/函数 自动 schema 生成 注册
Args / KFunction ──▶ ToolDescriptor(中间表示) ──▶ ToolRegistry
@Tool @LLMDescription requiredParameters 等 (按名字唯一)

▼ 各 Client 二次翻译
Provider JSON schema
(OpenAI / Anthropic ...)
━━━━━━━━━━━━━━━━━━━━━━━━━ LLM 这道墙 ━━━━━━━━━━━━━━━━━━━━━━━━━
运行期(调用 + 执行)
LLM 回一个 tool call
{name, args(JSON)}


SafeTool.execute(args) ← 你的代码这样发起
│ 编码 args→字符串,包成 Tool.Call

ContextualAgentEnvironment ← 发 onToolCallStarting 等事件、合并 metadata


GenericAgentEnvironment ← 注册表查工具 → decodeArgs → execute → encodeResult


ReceivedToolResult ──▶ 转成 SafeTool.Result.Success/Failure ──▶ 回到 LLM

部件一句话职责:

部件干什么在哪(相对 koog/)
ToolBase / Tool / SimpleTool工具的三层抽象基类,定义 execute 签名与编解码钩子agents/agents-tools/.../core/tools/ToolBase.ktTool.ktSimpleTool.kt
ToolCallMetadata每次调用的附加旁路上下文(如 trace id),不进 schema、不发给 LLM.../core/tools/ToolCallMetadata.kt
ToolDescriptor给 LLM 的「招聘启事」:名字、描述、参数列表(provider 无关的中间表示).../core/tools/ToolDescriptor.ktToolDescriptors.kt
schema 生成从类型/函数生成 JSON schema,再折成 ToolDescriptor.../core/tools/schema/SchemaGenerator*.kt
provider 翻译ToolDescriptor 再翻成某家 LLM 的 JSONprompt/.../openai/base/OpenAICompatibleToolDescriptorSchemaGenerator.kt
@Tool / @LLMDescription / ToolSet / ToolFromCallable让普通函数零样板变工具.../core/tools/annotations/*.../core/tools/reflect/*
ToolRegistry工具的注册与查找.../core/tools/ToolRegistry.kt
SafeTool + AIAgentEnvironment运行期安全执行路径agents/agents-core/.../environment/SafeTool.ktAIAgentEnvironment.kt
MCP 接入把外部 MCP server 的工具映射成本地工具agents/agents-mcp/.../McpTool*.kt

3. 核心原理(逐个机制,由浅入深)

3.1 工具抽象:三层基类与 execute 签名

它要解决的小问题: 不同工具需求差别很大——有的只想返回一段文本、有的需要读运行时上下文、有的需要一个 trace id。既要让「简单工具写起来简单」,又要让「复杂工具能拿到全部信息」,还要让框架能用一个统一入口调度所有工具。

思路: 把「统一调度入口」和「用户写起来的便利形态」拆开。底座 ToolBase 定义唯一的调度入口;上面派生几种便利形态,各自消化掉自己不关心的东西。

ToolBase<TArgs,TResult> ← 唯一调度入口:execute(args, metadata)
├─ Tool<TArgs,TResult> ← 常规:只写 execute(args),metadata 被丢弃
│ └─ SimpleTool<TArgs> ← 更简单:结果就是 String,原样回给 LLM
└─ AgentContextAwareTool<...> ← 想要 AIAgentContext?从 metadata 里取

唯一入口是带 metadata 的那个。 ToolBase 只声明一个抽象方法,框架运行期永远调它(ToolBase.kt:97,execute(args: TArgs, metadata: ToolCallMetadata))。TArgs/TResult 是泛型,配一对 TypeToken(argsType/resultType)在运行期承载类型信息,用于编解码(ToolBase.kt:37-43)。

常规工具用 Tool,不必管 metadata。 Tool 把带 metadata 的重载 final override 掉,转发给只收参数的 execute(args),把 metadata 丢弃(Tool.kt:62-65)。所以 99% 的工具只需实现 execute(args): TResult

// 真实源码 Tool.kt:64 —— 常规工具的 metadata 就是在这里被丢掉的
final override suspend fun execute(args: TArgs, metadata: ToolCallMetadata): TResult =
execute(args)

SimpleTool 再省一步: 它固定 TResult = String,并覆盖 encodeResultToString 为「原样返回」(SimpleTool.kt:22)——因为结果本身就是要给 LLM 的文本,不需要 JSON 序列化。

要运行时上下文的用 AgentContextAwareTool 它住在 agents-core(不是 agents-tools),因为要依赖 AIAgentContext。它的 final override 从 metadata 里按保留键取出上下文,取不到就抛异常(说明这个工具被在 agent 运行之外错误地调用了),取到就转发给带 context 参数的重载(AgentContextAwareTool.kt:71-79)。这个「分层」是刻意的:agents-tools 不能反向依赖 agents-core,所以「要 context」这件事被下沉到 agents-core 里的子类(ToolBase.kt:22-27 的注释点明了这个设计动机)。

ToolCallMetadata 是什么: 一个 Map<String,Any?> 的只读包装(ToolCallMetadata.kt:18-20,用 Kotlin 的 by 委托直接实现 Map)。关键性质写在类注释里:它是严格附加的旁路——不属于参数 schema、不序列化给 LLM、不能用于路由或选工具(ToolCallMetadata.kt:3-9)。典型用途是 trace span id、correlation id。空实例是共享单例 EMPTY(ToolCallMetadata.kt:51),plus 做合并且后者覆盖前者(ToolCallMetadata.kt:26-30)。

ToolBase 上还挂了一整套编解码钩子,都是 open 可覆盖的:decodeArgs / encodeArgs(参数 JSON ↔ 对象)、encodeResult / encodeResultToString(结果 → JSON / 给 LLM 的文本)、encodeResultToParts(结果 → 多模态内容块,默认包一个文本)(ToolBase.kt:127-269)。运行期就是靠这几个钩子在 JSON 与强类型之间来回翻译。

3.2 对 LLM 的契约:ToolDescriptor 与两段式 schema 生成

它要解决的小问题: LLM 只认 JSON schema,而且每家 provider 的 schema 方言还不一样(OpenAI、Anthropic、Google……)。你不想为每家 provider 手写一遍工具描述。

思路:两段翻译,中间夹一个 provider 无关的中间表示(IR)。

第 1 段(agents-tools,创作期,只做一次)
Kotlin 类型/函数 ──▶ JSON schema ──▶ ToolDescriptor(IR)
getJsonSchema requiredParameters / optionalParameters
每项是 ToolParameterDescriptor(type: ToolParameterType)

第 2 段(prompt-executor,发请求时,按 provider)
ToolDescriptor(IR) ──▶ 某家 provider 的 JSON
ToolDescriptorSchemaGenerator.generate(...)

ToolDescriptor 就是那个 IR。 它只有四样东西:namedescriptionrequiredParametersoptionalParameters,外加可选的 cacheControl(ToolDescriptor.kt:17-23)。每个参数是 ToolParameterDescriptor(name, description, type),而 type 是一个 sealed class ToolParameterType,枚举了 String/Integer/Float/Boolean/Enum/List/Object/AnyOf/Null 这些跨 provider 通用的类型形状(ToolDescriptors.kt:23-48)。这一层刻意不带任何 provider 语法。

第 1 段怎么生成: 入口是 getToolDescriptor(argsType, name, description, ...)(schema/SchemaGenerator.kt:66)。它先调 getJsonSchema 把参数类型变成 JSON schema,要求顶层必须是 object(否则报错),再把 schema 的每个 property 用 toToolParameter 折成 ToolParameterType,并按「在不在 required 里」分成必填/选填(SchemaGenerator.kt:72-95)。ToolBase 的便利构造器就是在这里被触发的——你 new 一个工具、只给了 name/description/argsType,descriptor 就被自动算出来(ToolBase.kt:59-70)。

getJsonSchema 是 expect/actual(多平台)。 JVM 实现优先走 kotlinx.serialization 的 KSerializer(即你的 @Serializable 类),生成不了才回退到反射(SchemaGenerator.jvmCommon.kt:51-98,findKSerializer:140)。描述文字从哪来?一个 descriptionExtractor 专门从注解里捞 @LLMDescription 的值(SchemaGenerator.kt:35-40)——这就是参数说明的来源。

第 2 段怎么翻译: 接口 ToolDescriptorSchemaGenerator 只有一个方法 generate(toolDescriptor): JsonObject(serialization/ToolDescriptorSchemaGenerator.kt:9-17)。每家 Client 有自己的实现。看 OpenAI 版最直观:遍历 requiredParameters + optionalParametersproperties,把必填名字塞进 required 数组,再按 ToolParameterType 逐类映射到 "type":"string" 之类(OpenAICompatibleToolDescriptorSchemaGenerator.kt:19-82)。

一个真实的坑:nullable 的表示。 Koog 的 IR 暂时没有原生「类型并集」,nullable 参数被表示成 AnyOf[Null, 实际类型](见 SchemaGenerator.kt:186-196)。但 Anthropic 等 provider 的工具 schema 不支持 anyOf。于是 ToolParameterType.AnyOf 上挂了一个专门的 hack 方法 hackRepresentAnyOfWithNullAsTypeUnionWithNull,把「AnyOf[Null, X]」这种特例识别出来、改写成 "type":["x","null"] 的并集形式(ToolDescriptors.kt:132-162,方法名和注释都直言这是 hack、待正式支持并集类型后移除)。这是「IR 通用、末端按 provider 特判」的典型样子。

3.3 零样板:@Tool / @LLMDescription / ToolSet / ToolFromCallable

它要解决的小问题: 3.1 那种继承 SimpleTool、手写 Args data class 的写法,对「我只想把一个已有函数暴露给 LLM」还是太重。

思路:用注解 + 反射,把一个普通 KFunction 直接包成工具。

两个注解各管一件事:

注解贴在哪作用
@Tool(customName)函数上标记「这个函数要被收集成工具」,可选自定义名字
@LLMDescription(value)类/函数/参数/属性上提供给 LLM 的自然语言描述

@Tool 只作用于函数、且只是个纯标记(annotations/Tool.kt:8-10)。@LLMDescription 目标很宽(类、函数、参数、属性、类型都行),而且带 @SerialInfo——意味着它能被 kotlinx.serialization 在生成 schema 时读到,这正是 3.2 里 descriptionExtractor 能捞到它的原因(annotations/LLMDescription.kt:11-19)。

ToolFromCallable 是那个「包装器」工具。 它继承 Tool<ToolFromCallable.Args, TResult>,argsType 固定为 JSONObject,resultType 从 callable 的返回类型算出,descriptor 用 3.2 里面向函数getToolDescriptor(callable, ...) 重载生成(reflect/ToolFromCallable.kt:28-41;函数版 descriptor 在 SchemaGenerator.jvmCommon.kt:110,注意函数调用 schema 里所有参数都当作必填,见 :119-120)。

它覆盖了 decodeArgs:把 LLM 传来的 JSON 按参数名逐个解码成 KParameter → 值 的 map(ToolFromCallable.kt:54-64);execute 则用 callSuspendBy 反射调用真函数,并把 InvocationTargetException 拆包成原始异常(ToolFromCallable.kt:86-100)。suspend 与非 suspend、默认参数都支持(默认参数可在 JSON 里省略)。

批量收集靠 ToolSetasTools() ToolSet 是个标记接口,asTools() 反射扫本类所有带 @Tool 的方法、各包成 ToolFromCallable(reflect/ToolSet.kt:10-29)。底层是扩展函数 KClass.asTools(thisRef)(ToolFromCallableExtensions.kt:62-73)。

一个细节:注解可从被覆盖的父方法继承。 getPreferredToolAnnotation 先看本方法有没有 @Tool,没有就顺着实现的接口/父类方法找(ToolFromCallableExtensions.kt:116-146)。所以你可以在接口上写 @Tool @LLMDescription,实现类不重复标注也能被收集。

// 示意,非源码:一个 ToolSet,两个方法零样板变工具
class MathTools : ToolSet {
@Tool // 标记要收集
@LLMDescription("把两个整数相加") // 给 LLM 的说明
fun add(a: Int, b: Int): Int = a + b
}

val registry = ToolRegistry { tools(MathTools().asTools()) }

3.4 注册表:ToolRegistryToolRegistryBuilder

它要解决的小问题: agent 需要一份「我手上有哪些工具」的清单,而且要能按名字(LLM 回的 tool call 只带名字)和按类型(你的代码想拿具体工具)两种方式查。

ToolRegistry 内部就是一个 List<ToolBase<*,*>>,对外暴露只读副本(ToolRegistry.kt:32-50)。核心方法:

方法作用位置
getToolOrNull(name) / getTool(name)按名字查,后者查不到抛异常ToolRegistry.kt:61 / :74
getTool<T>()按 reified 类型查(it::class == T::class)ToolRegistry.kt:88
operator plus合并两个注册表,按名字去重(distinctBy { it.name })ToolRegistry.kt:104
add / addAll运行期追加(已存在则跳过)ToolRegistry.kt:114 / :126

构造走 builder DSL: ToolRegistry { tool(...) } 这个尾随 lambda 构造器,内部就是 ToolRegistryBuilder().apply(init).build()(ToolRegistry.kt:39)。ToolRegistryBuilderexpect class(多平台各有 actual),提供 tool() / tools() / build()(ToolRegistryBuilder.kt:9-24)。名字唯一性在这里强制:内部 addToolrequire 检查重名,重复就抛「Tool xxx is already defined」(ToolRegistryBuilder.kt:26-29)。

注意 plus(合并)是静默去重,而 builder 里的 addTool(构建)是重名报错——两条路径对重名的态度不同,别混淆。

3.5 安全执行路径:SafeTool + environment(为什么不能直接调 execute)

它要解决的小问题: 你手上有一个 Tool 实例,execute 就是个 public suspend 方法,直接 call 不就完了?——不行。直接 call 会绕过一整条管线:参数解码、结果编码、feature 事件、错误转换、上下文注入,全没了。

ToolBase 的文档直接点名这件事:不推荐直接调工具,会导致「feature 管线出 bug」「无法测试/mock」,应当去拿一个 SafeToolexecute(ToolBase.kt:83-92)。

这条链路怎么走的(接 §2 下半图,给出符号):

SafeTool.execute(args, serializer, metadata) SafeTool.kt:127
│ tool.encodeArgsToString(args) → 包成 MessagePart.Tool.Call

environment.executeTool(call, metadata) AIAgentEnvironment.kt:41

├─ ContextualAgentEnvironment(装饰器) ContextualAgentEnvironment.kt:51
│ ├─ pipeline.onToolCallStarting(...) :101 ← feature 观测点
│ ├─ featureMetadata = pipeline.collectToolCallMetadata(...) :112
│ ├─ merged = featureMetadata + 调用方 metadata + {AgentContextKey: context} :126
│ │ (调用方覆盖 feature;框架注入的 context 最后写、永远赢)
│ ├─ environment.executeTool(call, merged) :129 ← 传给下层
│ └─ pipeline.onToolCallCompleted/Failed/ValidationFailed(...) :160+

└─ GenericAgentEnvironment(终点,真正干活) GenericAgentEnvironment.kt:28
├─ registry.getToolOrNull(name) 查不到 → 结构化 Failure :79
├─ tool.decodeArgs(json) 解码失败 → 结构化 Failure :98
├─ (tool as ToolBase<Any?,Any?>).execute(args, metadata) :117 ← 唯一真正 call
│ catch ToolException → ValidationError;catch 其它 → Failure
└─ encodeResultToString / encodeResult / encodeResultToParts :148

ReceivedToolResult ──▶ toSafeResult(tool, serializer) ──▶ Success / Failure SafeTool.kt:181

看清三件「直接调 execute 会丢掉」的事:

  1. feature 管线全断。 onToolCallStartingcollectToolCallMetadataonToolCallCompleted/Failed 这些都在 ContextualAgentEnvironment 里发出(ContextualAgentEnvironment.kt:101-190)。直接调 execute 一个事件都不会触发——tracing、event handler 等 feature 全部失明。
  2. 错误不再是结构化结果。 GenericAgentEnvironment 把每一步的异常都 catch 成一个 ReceivedToolResult(带 ToolResultKind.Failure/ValidationError)喂回 LLM 让它重试(GenericAgentEnvironment.kt:79-144)。直接调 execute 则是异常直接往上抛,agent 循环可能就崩了。
  3. 上下文注入没了。 AgentContextAwareTool 依赖 merged 里注入的 AgentContextKey(ContextualAgentEnvironment.kt:126-127);直接调会取不到 context 而抛 IllegalStateException(AgentContextAwareTool.kt:72-77)。

SafeTool 的产出是个 sealed Result 成功是 Success(result, content),失败是 Failure(message),带 isSuccessful()/asSuccessful() 等便利判定(SafeTool.kt:28-96)。从 ReceivedToolResult 转过来的逻辑:result 为 null 直接算失败;否则尝试 decodeResult,解码异常也算失败(toSafeResult,SafeTool.kt:181-193)。

TerminationTool —— 一个保留名字,不是真工具。 它只是常量:NAME = "__terminate__"ARG = "result"(environment/TerminationTool.kt:9-26)。agent 想「结束并给出最终答案」时用这个保留名发一个 tool call,循环识别到它就收尾(识别与主循环在 01-graph-engine.md)。

校验用 ToolException 工具里 validate(...) / validateNotNull(...) / fail(...) 抛的是 ToolException.ValidationFailure(ToolException.kt:9-54),它在终点被专门 catch、转成 ToolResultKind.ValidationError(而非普通 Failure)(GenericAgentEnvironment.kt:120-130)——语义上区分「工具校验拒绝」与「工具执行崩了」。

3.6 外部工具接入:MCP

它要解决的小问题: 别人已经用 Model Context Protocol(MCP,一个让工具/数据源以标准协议对外暴露的规范)跑了一个 server。你想把它上面的工具直接拿来用,而不是重写一遍。

思路:连上 MCP server → 拉工具清单 → 把每个工具的 schema 翻成 ToolDescriptor → 各包成一个 McpTool → 塞进 ToolRegistry 对 agent 而言,MCP 工具和本地工具长得完全一样

入口 McpToolRegistryProvider 支持三种传输:Streamable HTTP(推荐)、stdio、SSE(McpToolRegistryProvider.kt:31-230)。核心是 fromClient:mcpClient.listTools() 拿到 SDK 工具列表,buildToolRegistry 逐个 parse → 包 McpTool → tool(...),单个工具解析失败只记日志、不影响其它(McpToolRegistryProvider.kt:145-185)。注册时还把 MCP 元信息(工具 id、协议版本、传输类型、server url/port)塞进工具的静态 metadata(:164-179)。

McpTool 是桥。 它是 Tool<JSONObject, CallToolResult?>:execute 直接把 JSONObject 参数转成 kotlinx JSON,调 mcpClient.callTool(McpTool.kt:59-64)。它还覆盖了 encodeResultToString 做「给 LLM 看的清理」:出错时加 Error: 前缀便于模型识别失败并改策略,正常时剔掉 _meta 等模型不需要的字段(McpTool.kt:83-104)。

schema 方向反过来了。 本地工具是「Kotlin 类型 → JSON schema → ToolDescriptor」;MCP 工具的 server 已经给了 JSON schema,所以 DefaultMcpToolDescriptorParser.parse 是「MCP inputSchema(JSON)→ ToolDescriptor」(McpToolDefinitionParser.kt:46-62)。它递归解析 JSON schema 的各种形状——$ref 解引用、type-array(["string","null"])转 nullable、anyOfenumarray/object 嵌套,并设了 MAX_DEPTH=30 防循环引用(McpToolDefinitionParser.kt:64-221)。终点相同:不管哪条路,最后都汇到同一个 ToolDescriptor IR,再由 §3.2 第 2 段翻给 provider。


4. 巧妙之处(可借鉴的技术)

  • 一个 IR 隔离两侧变化。 ToolDescriptor 把「工具怎么定义(Kotlin 类型 / 反射函数 / MCP JSON)」和「工具怎么暴露(OpenAI / Anthropic / Google / Ollama ...)」彻底解耦——三种来源、N 种 provider,中间只有一个稳定的 ToolParameterType sealed 集合。要加一家 provider,只写一个 ToolDescriptorSchemaGenerator 实现即可(ToolDescriptors.kt:48serialization/ToolDescriptorSchemaGenerator.kt:9)。
  • 把「附加上下文」做成不进 schema 的旁路。 ToolCallMetadata 让 trace id / context 这类横切信息搭同一趟车传进 execute,却对 LLM 完全不可见,也不污染参数契约(ToolCallMetadata.kt:3-9)。
  • metadata 合并顺序即优先级。 featureMetadata + 调用方 + {AgentContextKey} 的加法顺序决定了「调用方覆盖 feature、框架注入的 context 永远赢」——用不可变 map 的覆盖语义直接编码优先级,无需额外判断(ContextualAgentEnvironment.kt:126-127)。
  • 异常在终点被翻成「可回给 LLM 的失败」。 参数解析、找不到工具、执行、结果编码,每一步的异常都变成结构化 ReceivedToolResult,让 agent 循环能把失败当反馈继续,而不是整体崩溃(GenericAgentEnvironment.kt:79-167)。
  • 不安全类型转换配带工具名的报错。 withUnsafeCastClassCastException 里补上工具名和上下文——并发多工具场景里,这比裸 CCE 好查太多(ToolBase.kt:298-316)。

5. 边界与局限

  • 类型并集尚未原生支持。 nullable 靠 AnyOf[Null, X] + provider 端 hack 表达,ToolParameterType.AnyOf 的注释与 hackRepresentAnyOfWithNullAsTypeUnionWithNull 都写明这是临时方案(ToolDescriptors.kt:106-162)。
  • 反射建工具是 JVM 专属。 ToolFromCallable / ToolSet 全在 jvmCommonMain,用了 kotlin.reflect.fullcallSuspendBy(reflect/ToolFromCallable.kt);非 JVM 平台没有这条「零样板」路径。
  • 工具 schema 顶层必须是 object。 getToolDescriptor 对非 object 顶层直接抛异常(SchemaGenerator.kt:74)——不能把一个裸 String/Int 当参数类型。
  • ToolRegistry 语义上不可变却存了可变列表。 源码里挂着 TODO(KG-676) 承认这点(ToolRegistry.kt:146-149);add/addAll 会原地改。
  • 函数式工具「所有参数皆必填」。ToolFromCallable 的函数,其 descriptor 把参数一律当 required(SchemaGenerator.jvmCommon.kt:119-120),可选性靠 Kotlin 默认参数在运行期兜。
  • 自定义 environment 可能悄悄丢掉 metadata。 executeTool(call, metadata) 的默认实现会退化为丢弃 metadata 的单参版本(AIAgentEnvironment.kt:41-44);只覆盖了单参重载的自定义 environment 会静默丢失 metadata。

6. 横向对比(同组其它章)

  • 工具被谁调用、何时调用——节点的 edge 谓词、ToolSelectionStrategy、tool-call 主循环:01-graph-engine.md
  • 用 DSL / 预制节点把工具编排进 strategy:02-dsl-and-strategies.md
  • ToolDescriptor 最终怎么进 prompt、发给哪个 Client,provider 翻译的完整名单:03-llm-layer.md
  • 工具执行时的 feature 拦截、观测、持久化(本章只讲到管线发出事件的那几个点):05-features-runtime-extensions.md

8. 代码地图(导航索引)

主题文件(相对 koog/)关键符号
工具调度底座 + 编解码钩子agents/agents-tools/src/commonMain/kotlin/ai/koog/agents/core/tools/ToolBase.ktToolBaseexecutedecodeArgsencodeResultToStringwithUnsafeCast
常规工具(无 metadata).../core/tools/Tool.ktToolexecute(args)final override execute(args, metadata)
字符串结果工具.../core/tools/SimpleTool.ktSimpleToolencodeResultToString
每次调用旁路上下文.../core/tools/ToolCallMetadata.ktToolCallMetadataEMPTYofplus
要 AIAgentContext 的工具agents/agents-core/src/commonMain/kotlin/ai/koog/agents/core/agent/tools/AgentContextAwareTool.ktAgentContextAwareToolAgentContextKeyagentContext
LLM 契约 IR.../core/tools/ToolDescriptor.ktToolDescriptors.ktToolDescriptorToolParameterDescriptorToolParameterTypehackRepresentAnyOfWithNullAsTypeUnionWithNull
schema 生成(类型→IR).../core/tools/schema/SchemaGenerator.ktSchemaGenerator.jvmCommon.ktgetToolDescriptorgetJsonSchematoToolParameterfindKSerializer
IR→provider JSON.../core/tools/serialization/ToolDescriptorSchemaGenerator.kt;prompt/.../openai/base/OpenAICompatibleToolDescriptorSchemaGenerator.ktToolDescriptorSchemaGeneratorOpenAICompatibleToolDescriptorSchemaGeneratorfillJsonSchema
注解.../core/tools/annotations/Tool.ktLLMDescription.kt@Tool@LLMDescription
反射建工具.../core/tools/reflect/ToolSet.ktToolFromCallable.ktToolFromCallableExtensions.ktToolSetasToolsToolFromCallableasToolgetPreferredToolAnnotation
注册表.../core/tools/ToolRegistry.ktToolRegistryBuilder.ktToolRegistrygetToolOrNullgetTool<T>plusToolRegistryBuilder.addTool
安全执行agents/agents-core/.../environment/SafeTool.ktSafeToolexecuteResult.Success/FailuretoSafeResult
environment 接口/装饰器/终点.../environment/AIAgentEnvironment.ktContextualAgentEnvironment.ktGenericAgentEnvironment.ktexecuteToolcollectToolCallMetadataprocessToolCall
工具结果 / 终止 / 校验.../environment/ReceivedToolResult.ktTerminationTool.kt;.../core/tools/ToolException.ktReceivedToolResultTerminationTool.NAMEToolException.ValidationFailurevalidate
MCP 接入agents/agents-mcp/.../McpToolRegistryProvider.ktMcpTool.ktMcpToolDefinitionParser.ktMcpToolRegistryProvider.fromClientMcpTool.executeDefaultMcpToolDescriptorParser.parse