DEV Community

Solon Framework
Solon Framework

Posted on

Four Ways to Build Solon AI Tools: Annotations, FunctionToolDesc, returnDirect, and toolContext

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.";
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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 @Param or @Body enter inputSchema.
  • 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();
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

With returnDirect = true:

user -> llm -> tool -> user
Enter fullscreen mode Exit fullscreen mode

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) { ... }
Enter fullscreen mode Exit fullscreen mode

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.");
Enter fullscreen mode Exit fullscreen mode

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:

  1. Put host values with defaultToolContextPut / toolContextPut.
  2. On the tool method, do not mark those parameters with @Param.
  3. If a name collides with an LLM argument, toolContext wins.
// 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();
Enter fullscreen mode Exit fullscreen mode
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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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) so outputSchema is 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());
Enter fullscreen mode Exit fullscreen mode

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)

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)