Most Solon agent demos hang tools with AbsToolProvider + @ToolMapping and stop there. That covers 90% of business code. The remaining 10% shows up the first time you need a tool built at runtime, a short-circuit path that skips a second LLM pass, or a tenant id that must never be invented by the model.
Solon AI (v4.0.3) treats tools as first-class objects shared by ChatModel, ReActAgent, and MCP. Official docs list four customization styles, plus two production knobs that are easy to miss: returnDirect and toolContext.
This article stays on those official APIs only.
What you get beyond “one annotated class”
| Need | Official lever |
|---|---|
| Multiple methods in one class |
AbsToolProvider + @ToolMapping
|
| Build a tool from config / plugin metadata | FunctionToolDesc |
| Full control of schema and lifecycle |
FunctionTool or AbsTool
|
| Skip the second LLM rewrite after a tool | returnDirect = true |
| Inject auth / tenant without teaching the LLM |
toolContext (no @Param) |
| Scope tools |
defaultToolAdd (always on) vs options.toolAdd (this request) |
All of the customization styles are also valid MCP tool definitions — same model, different transport.
1. Style A — default: AbsToolProvider + @ToolMapping
Use this for normal domain tools. One class can expose many methods. Solon turns method metadata into inputSchema / outputSchema.
import org.noear.solon.ai.annotation.ToolMapping;
import org.noear.solon.ai.chat.tool.AbsToolProvider;
import org.noear.solon.annotation.Param;
public class OrderTools extends AbsToolProvider {
@ToolMapping(description = "Query order status by order id")
public String get_order(@Param(name = "orderId", description = "Order id from the user") String orderId) {
return "{\"orderId\":\"" + orderId + "\",\"status\":\"SHIPPED\"}";
}
@ToolMapping(
name = "cancel_order",
title = "Cancel order",
description = "Cancel an unpaid order. Returns a short confirmation string.",
returnDirect = true // tool result goes straight to the caller
)
public String cancel_order(@Param(description = "Order id") String orderId) {
return "Order " + orderId + " cancelled.";
}
}
Hang it on a chat model or a ReAct agent the same way:
// ChatModel — request-scoped tools
chatModel.prompt("What is the status of order A1001?")
.options(o -> o.toolAdd(new OrderTools()))
.call();
// ReActAgent — default tools for every turn
ReActAgent agent = ReActAgent.of(chatModel)
.name("order_agent")
.role("Order assistant")
.defaultToolAdd(new OrderTools())
.build();
Annotation details that matter
| Attribute | Role |
|---|---|
name |
Tool id for the model (defaults to method name) |
title |
Display title (useful on MCP admin UIs) |
description |
When the model should call this tool |
returnDirect |
If true, skip the post-tool LLM rewrite |
resultConverter |
Optional post-processing of the tool result |
Parameter rules from the docs:
- Only parameters with
@Paramor@BodyenterinputSchema. - Parameters without those annotations are not asked from the LLM — they come from
toolContext(or other host injection). - On POJO fields, missing annotations default to optional.
2. Style B — dynamic: FunctionToolDesc
Use this when the tool catalog is not a fixed Java class: plugin registry, admin console, feature flag, remote schema dump, etc.
import org.noear.solon.ai.chat.tool.FunctionToolDesc;
FunctionToolDesc weatherTool = new FunctionToolDesc("get_weather")
.description("Get weather for a city mentioned by the user")
.stringParamAdd("location", "City name inferred from the user text")
.returnType(String.class)
.returnDirect(false)
.doHandle(args -> {
String location = (String) args.get("location");
return "Sunny, 24C in " + location;
});
chatModel.prompt("Weather in Hangzhou?")
.options(o -> o.toolAdd(weatherTool))
.call();
Fluent helpers you can rely on from the javadoc: stringParamAdd, intParamAdd, floatParamAdd, boolParamAdd, dateParamAdd, paramAdd, returnType, returnDirect, doHandle.
3. Style C / D — FunctionTool and AbsTool
Raw FunctionTool is for advanced control (custom schema assembly, strict validation). Official docs recommend AbsTool when you want that control without implementing every interface method by hand.
import org.noear.solon.ai.chat.tool.AbsTool;
import java.util.Map;
public class WeatherTool extends AbsTool {
public WeatherTool() {
addParam("location", String.class, true, "City name inferred from the user text");
}
@Override
public String description() {
return "Get weather for a city mentioned by the user";
}
@Override
public String handle(Map<String, Object> args) {
String location = (String) args.get("location");
if (location == null) {
throw new IllegalStateException("arguments location is null (Assistant recognition failure)");
}
return "Sunny, 24C in " + location;
}
}
The full implements FunctionTool form still works — you build ParamDesc yourself and call ToolSchemaUtil.buildInputSchema(params). Prefer AbsTool unless you need that extra surface.
4. Registration scope: default vs request
Same tool object, two scopes:
// Always attached to every request on this model
ChatModel chatModel = ChatModel.of(config)
.defaultToolAdd(new OrderTools())
.defaultToolAdd(weatherTool)
.build();
// Attached only for this prompt
chatModel.prompt("Cancel order A1001 if unpaid")
.options(o -> o.toolAdd(new OrderTools()))
.call();
On agents, ReActAgent.of(...).defaultToolAdd(...) is the usual production path so every Reason→Act turn sees the same catalog.
5. returnDirect: when the second LLM hop is waste
Default tool flow:
user -> llm -> tool -> llm -> user
With returnDirect = true:
user -> llm -> tool -> user
That is not a micro-optimization slogan — it is the right shape when:
- the tool already returns a user-facing confirmation (
"Order A1001 cancelled."); - you are building a thin “run this command” agent and do not want the model to paraphrase;
- latency / token cost of a second completion is pure overhead.
Set it on @ToolMapping:
@ToolMapping(description = "Cancel an unpaid order", returnDirect = true)
public String cancel_order(@Param(description = "Order id") String orderId) { ... }
Or on FunctionToolDesc:
new FunctionToolDesc("cancel_order")
.description("Cancel an unpaid order")
.stringParamAdd("orderId", "Order id")
.returnDirect(true)
.doHandle(args -> "Order " + args.get("orderId") + " cancelled.");
MCP note (from official docs): the open MCP protocol does not generally pass this flag through. When both server and client are solon-ai-mcp, Solon can preserve returnDirect end-to-end.
6. toolContext: host data the LLM must not invent
Auth tokens, tenant ids, operator names, locale — anything that should come from the application, not from the model’s imagination — belongs in toolContext.
Rules:
- Put host values with
defaultToolContextPut/toolContextPut. - On the tool method, do not mark those parameters with
@Param. - If a name collides with an LLM argument,
toolContextwins.
// Model-level default context
ChatModel chatModel = ChatModel.of(config)
.defaultToolAdd(new SecureOrderTools())
.defaultToolContextPut("tenantId", currentTenant())
.defaultToolContextPut("operator", currentUser())
.build();
// Or request-level
chatModel.prompt("Show order A1001")
.options(o -> o.toolAdd(new SecureOrderTools())
.toolContextPut("tenantId", currentTenant())
.toolContextPut("operator", currentUser()))
.call();
public class SecureOrderTools extends AbsToolProvider {
@ToolMapping(description = "Query order status inside the current tenant")
public String get_order(
@Param(description = "Order id") String orderId,
String tenantId, // from toolContext — not in inputSchema
String operator // from toolContext — not in inputSchema
) {
return orderService.find(tenantId, orderId, operator);
}
}
For raw / dynamic tools, read the same keys from the args map that the framework merges before handle.
7. Schema hygiene (so models stop guessing)
Official guidance is boring and correct:
- Prefer basic types and plain data beans in tool signatures.
- Avoid leaking runtime objects (
Socket,Session, open streams) into schema. - Write descriptions that tell the model when to call and how to fill each field.
- Use
returnType(...)(or a real method return type) sooutputSchemais generated for MCP-facing tools.
ToolSchemaUtil is the shared builder behind annotation tools and manual tools (buildInputSchema, buildOutputSchema, createSchema).
8. Minimal production composition
ReActAgent agent = ReActAgent.of(chatModel)
.name("order_desk")
.role("Help users inspect and cancel their own orders")
.defaultToolAdd(new OrderTools()) // styles A + returnDirect
.defaultToolAdd(weatherTool) // style B if you need it
.defaultToolContextPut("tenantId", tenant) // host-side isolation
.maxTurns(8)
.modelOptions(o -> o.temperature(0.1))
.build();
var resp = agent.prompt("Cancel order A1001 if it is still unpaid").call();
System.out.println(resp.getContent());
You can still audit with resp.getTrace() / resp.getMetrics() on the enhanced prompt() API — tools stay simple; observability stays on the agent response.
Choose in one minute
| Situation | Prefer |
|---|---|
| Fixed business methods, DI, AOP |
AbsToolProvider + @ToolMapping
|
| Tools assembled from config / plugins | FunctionToolDesc |
| Custom validation / schema edge cases |
AbsTool (or raw FunctionTool) |
| Tool output is already the user answer | returnDirect = true |
| Tenant / auth / operator must be host-owned |
toolContext without @Param
|
| Tool needed on every turn | defaultToolAdd |
| Tool needed once | options(...).toolAdd(...) |
Official references (verified)
-
Tool interface & attributes —
returnDirect, schemas,FunctionTool - Four customization & registration styles — AbsToolProvider / FunctionToolDesc / FunctionTool / AbsTool
-
@ToolMapping details — annotation attributes,
@Param/@Bodyrules - Input / output schema & ToolSchemaUtil
- Tool context & extra args
- ReActAgent build / call options
- Agent response (
getTrace/getMetrics) - After-sales sample tool style: article/1285
Takeaway
You do not need a private tool framework on top of Solon AI.
Start with AbsToolProvider + @ToolMapping. Graduate to FunctionToolDesc when tools become data. Reach for AbsTool when schema control gets weird. Flip returnDirect when the tool already spoke to the user. Push host identity through toolContext so the model never has to invent a tenant id.
Same tool objects work on ChatModel, ReActAgent, and MCP. Keep the catalog small, the descriptions honest, and the host data out of the prompt.
Top comments (0)