Skip to content

tio-boot 调用 Agent CLI:agent-cli-sdk ​

简介 ​

编码 Agent(读文件、改代码、跑命令、调用 MCP 工具)通常以命令行程序的形式发布。agent-cli-sdk 把这类 CLI 变成一个普通的 Java 依赖:SDK 以子进程启动 CLI,通过 stdin/stdout 上的 stream-json 控制协议通信,业务代码拿到的是类型化的消息和回调。

配合 tio-boot 可以很快搭出一个 Agent 服务:

  • 一次性运行一个 prompt,返回结果、轮次、费用和工具调用记录;
  • 用 SSE 把 Agent 的文字、工具调用和结果实时推给前端;
  • 保持多轮会话,回合进行中查询状态或中断;
  • 在 JVM 里实现 MCP 工具,把业务数据直接交给 Agent,不需要额外进程;
  • 在服务端统一决定哪些工具可以执行。

SDK 没有第三方依赖,不会和项目里的 JSON 库冲突;它不绑定某一个 CLI,可执行文件路径由配置给出。

添加依赖 ​

xml
<dependency>
  <groupId>nexus.io</groupId>
  <artifactId>agent-cli-sdk</artifactId>
  <version>${agent-cli-sdk.version}</version>
</dependency>

配置 ​

app.properties:

properties
# Agent CLI 可执行文件,也可以用环境变量 AGENT_CLI_PATH
agent.cli.path=
# 额外 CLI 参数,格式为 --flag value
agent.cli.args=
agent.workspace=
agent.permission.mode=default
agent.allowed.tools=Read,Grep,Glob
agent.denied.tools=Bash
agent.query.timeout.seconds=600
app.api.token=

EnvUtils 会把 agent.cli.path 自动映射到环境变量 AGENT_CLI_PATH,部署时不用改配置文件。

构建 SDK 选项 ​

选项的组装放在 service 里,handler 只负责参数和响应。

java
package nexus.io.agent.demo.service;

import java.io.File;

import nexus.io.agent.demo.model.AgentQueryRequest;
import nexus.io.agent.sdk.AgentOptions;
import nexus.io.agent.sdk.PermissionMode;
import nexus.io.jfinal.aop.Aop;
import nexus.io.tio.utils.environment.EnvUtils;

public class AgentOptionsService {

  public AgentOptions build(AgentQueryRequest input, boolean partialMessages) {
    AgentOptions options = new AgentOptions()
        .cliPath(EnvUtils.getStr("agent.cli.path"))
        .permissionMode(permissionMode(input.getPermissionMode()))
        .canUseTool(Aop.get(AgentPermissionService.class))
        .mcpServer(Aop.get(AgentToolService.class).server())
        .includePartialMessages(partialMessages);
    String workspace = EnvUtils.getStr("agent.workspace");
    if (workspace != null && !workspace.isEmpty()) {
      options.cwd(new File(workspace));
    }
    for (String tool : EnvUtils.getStr("agent.allowed.tools", "").split(",")) {
      if (!tool.trim().isEmpty()) {
        options.allowedTools(tool.trim());
      }
    }
    return options;
  }

  public PermissionMode permissionMode(String requested) {
    String name = requested != null ? requested : EnvUtils.getStr("agent.permission.mode", "default");
    for (PermissionMode mode : PermissionMode.values()) {
      if (mode.value().equals(name)) {
        return mode;
      }
    }
    throw new IllegalStateException("agent.permission.mode is invalid: " + name);
  }
}

请求实体:

java
package nexus.io.agent.demo.model;

public class AgentQueryRequest {
  private String prompt;
  private String model;
  private String permissionMode;

  public String getPrompt() {
    return prompt;
  }

  public void setPrompt(String prompt) {
    this.prompt = prompt;
  }

  public String getModel() {
    return model;
  }

  public void setModel(String model) {
    this.model = model;
  }

  public String getPermissionMode() {
    return permissionMode;
  }

  public void setPermissionMode(String permissionMode) {
    this.permissionMode = permissionMode;
  }
}

服务端工具权限 ​

CanUseTool 在每次需要确认的工具调用时执行,服务端可以在这里落实统一的安全策略。

java
package nexus.io.agent.demo.service;

import java.util.Map;

import nexus.io.agent.sdk.permission.CanUseTool;
import nexus.io.agent.sdk.permission.PermissionResult;
import nexus.io.agent.sdk.permission.ToolPermissionContext;
import nexus.io.tio.utils.environment.EnvUtils;

public class AgentPermissionService implements CanUseTool {

  @Override
  public PermissionResult check(String toolName, Map<String, Object> input, ToolPermissionContext context) {
    for (String denied : EnvUtils.getStr("agent.denied.tools", "Bash").split(",")) {
      if (denied.trim().equals(toolName)) {
        return PermissionResult.deny("Tool " + toolName + " is disabled on this server");
      }
    }
    return PermissionResult.allow();
  }
}

被拒绝的工具会以错误结果返回给 Agent,Agent 会据此调整做法,回合不会中断。需要直接结束回合时返回 PermissionResult.denyAndInterrupt(message)。

在 JVM 中提供 MCP 工具 ​

SdkMcpServer 运行在应用进程里,CLI 通过控制协议调用它,工具可以直接使用应用里的 service 和数据。Agent 看到的工具名是 mcp__app__server_time。

java
package nexus.io.agent.demo.service;

import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.LinkedHashMap;

import nexus.io.agent.sdk.json.JsonMaps;
import nexus.io.agent.sdk.mcp.SdkMcpServer;
import nexus.io.agent.sdk.mcp.SdkTool;
import nexus.io.agent.sdk.mcp.ToolResult;

public class AgentToolService {

  private final SdkMcpServer server = SdkMcpServer.of("app",
      new SdkTool("server_time", "Current date and time on the application server",
          JsonMaps.of("type", "object", "properties", new LinkedHashMap<String, Object>()),
          arguments -> ToolResult.text(ZonedDateTime.now().format(DateTimeFormatter.ISO_OFFSET_DATE_TIME))));

  public SdkMcpServer server() {
    return server;
  }
}

工具里抛出的异常会作为 isError 结果交给 Agent。

一次性查询接口 ​

service 启动 CLI、发送 prompt、收集消息,结束后关闭 CLI:

java
package nexus.io.agent.demo.service;

import com.jfinal.kit.Kv;

import nexus.io.agent.demo.model.AgentQueryRequest;
import nexus.io.agent.sdk.AgentClient;
import nexus.io.agent.sdk.message.AssistantMessage;
import nexus.io.agent.sdk.message.Message;
import nexus.io.agent.sdk.message.ResultMessage;
import nexus.io.agent.sdk.message.ToolUseBlock;
import nexus.io.jfinal.aop.Aop;

public class AgentQueryService {

  public Kv query(AgentQueryRequest input) {
    Kv toolCalls = Kv.create();
    try (AgentClient client = new AgentClient(Aop.get(AgentOptionsService.class).build(input, false))) {
      client.connect();
      client.query(input.getPrompt());
      ResultMessage result = client.awaitResult((Message message) -> {
        if (message instanceof AssistantMessage) {
          for (ToolUseBlock use : ((AssistantMessage) message).getToolUses()) {
            toolCalls.set(use.getId(), use.getName());
          }
        }
      });
      return Kv.by("sessionId", result.getSessionId())
          .set("subtype", result.getSubtype())
          .set("error", result.isError())
          .set("result", result.getResult())
          .set("numTurns", result.getNumTurns())
          .set("totalCostUsd", result.getTotalCostUsd())
          .set("toolCalls", toolCalls);
    }
  }
}

handler:

java
package nexus.io.agent.demo.handler;

import com.jfinal.kit.Kv;

import nexus.io.agent.demo.model.AgentQueryRequest;
import nexus.io.agent.demo.service.AgentQueryService;
import nexus.io.jfinal.aop.Aop;
import nexus.io.model.body.RespBodyVo;
import nexus.io.tio.boot.http.TioRequestContext;
import nexus.io.tio.http.common.HttpRequest;
import nexus.io.tio.http.common.HttpResponse;
import nexus.io.tio.utils.json.JsonUtils;
import nexus.io.tio.utils.validator.ParameterValidator;

/** POST /api/agent/query */
public class AgentQueryHandler {
  public HttpResponse handle(HttpRequest request) {
    AgentQueryRequest input = JsonUtils.parse(request.getBodyString(), AgentQueryRequest.class);
    ParameterValidator.require(input != null, "Request body must be a JSON object");
    input.setPrompt(ParameterValidator.text(input.getPrompt(), "prompt", 100000, true));
    AgentQueryService agentQueryService = Aop.get(AgentQueryService.class);
    Kv result = agentQueryService.query(input);
    return TioRequestContext.getResponse().respond(RespBodyVo.ok(result));
  }
}

返回示例:

json
{"code":1,"ok":true,"data":{"sessionId":"c854fdda-e169-4f2a-886a-15d226d0bf15","subtype":"success",
 "error":false,"result":"Tool said: 2026-10-24T03:18:35.698945093-10:00","numTurns":2,
 "totalCostUsd":0.0,"toolCalls":{"time-1":"mcp__app__server_time"}}}

SSE 流式接口 ​

includePartialMessages(true) 后 SDK 会收到 StreamEvent,getTextDelta() 就是模型输出的文字增量。Agent 运行时间较长,放在独立线程执行,handler 发送 SSE 响应头后立即返回。

java
package nexus.io.agent.demo.handler;

import java.nio.charset.StandardCharsets;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

import com.jfinal.kit.Kv;

import nexus.io.agent.demo.model.AgentQueryRequest;
import nexus.io.agent.demo.service.AgentOptionsService;
import nexus.io.agent.sdk.AgentClient;
import nexus.io.agent.sdk.message.AssistantMessage;
import nexus.io.agent.sdk.message.Message;
import nexus.io.agent.sdk.message.ResultMessage;
import nexus.io.agent.sdk.message.StreamEvent;
import nexus.io.http.common.sse.SsePacket;
import nexus.io.jfinal.aop.Aop;
import nexus.io.tio.boot.http.TioRequestContext;
import nexus.io.tio.core.ChannelContext;
import nexus.io.tio.core.Tio;
import nexus.io.tio.http.common.HttpRequest;
import nexus.io.tio.http.common.HttpResponse;
import nexus.io.tio.http.server.util.SseEmitter;
import nexus.io.tio.utils.json.JsonUtils;
import nexus.io.tio.utils.validator.ParameterValidator;

/** POST /api/agent/stream */
public class AgentStreamHandler {
  private static final ExecutorService STREAMS = Executors.newCachedThreadPool();

  public HttpResponse handle(HttpRequest request) {
    AgentQueryRequest input = JsonUtils.parse(request.getBodyString(), AgentQueryRequest.class);
    ParameterValidator.require(input != null, "Request body must be a JSON object");
    input.setPrompt(ParameterValidator.text(input.getPrompt(), "prompt", 100000, true));

    ChannelContext channelContext = request.getChannelContext();
    HttpResponse response = TioRequestContext.getResponse();
    response.addServerSentEventsHeader();
    Tio.bSend(channelContext, response);
    response.setSend(false);

    STREAMS.execute(() -> {
      try (AgentClient client = new AgentClient(Aop.get(AgentOptionsService.class).build(input, true))) {
        client.connect();
        client.query(input.getPrompt());
        client.awaitResult((Message message) -> {
          Kv event = null;
          if (message instanceof StreamEvent && ((StreamEvent) message).getTextDelta() != null) {
            event = Kv.by("event", "text").set("delta", ((StreamEvent) message).getTextDelta());
          } else if (message instanceof AssistantMessage && !((AssistantMessage) message).getToolUses().isEmpty()) {
            event = Kv.by("event", "tool_use").set("name", ((AssistantMessage) message).getToolUses().get(0).getName());
          } else if (message instanceof ResultMessage) {
            event = Kv.by("event", "result").set("result", ((ResultMessage) message).getResult());
          }
          if (event != null) {
            byte[] data = JsonUtils.toJson(event).getBytes(StandardCharsets.UTF_8);
            Tio.bSend(channelContext, new SsePacket().event(event.getStr("event")).data(data));
          }
        });
      } finally {
        SseEmitter.closeSeeConnection(channelContext);
      }
    });
    return response;
  }
}

测试:

bash
curl -s -N -H 'Authorization: Bearer change-me' -X POST localhost:8080/api/agent/stream -d '{"prompt":"what time is it"}'
event:tool_use
data:{"name":"mcp__app__server_time","event":"tool_use"}

event:text
data:{"delta":"Tool said: 2026-10-24T0","event":"text"}

event:text
data:{"delta":"3:18:41.739531948-10:00","event":"text"}

event:result
data:{"result":"Tool said: 2026-10-24T03:18:41.739531948-10:00","event":"result"}

多轮会话 ​

AgentClient 保持一个 CLI 进程,适合聊天类场景。会话可以放在 service 里按 id 管理,id 用雪花 ID:

java
package nexus.io.agent.demo.service;

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

import com.jfinal.kit.Kv;

import nexus.io.agent.demo.model.AgentQueryRequest;
import nexus.io.agent.sdk.AgentClient;
import nexus.io.agent.sdk.message.ResultMessage;
import nexus.io.jfinal.aop.Aop;
import nexus.io.model.exception.ParameterValidationException;
import nexus.io.tio.utils.snowflake.SnowflakeIdUtils;

public class AgentSessionService {
  private final Map<Long, AgentClient> sessions = new ConcurrentHashMap<Long, AgentClient>();

  public long create(AgentQueryRequest input) {
    AgentClient client = new AgentClient(Aop.get(AgentOptionsService.class).build(input, false));
    client.connect();
    long id = SnowflakeIdUtils.id();
    sessions.put(id, client);
    return id;
  }

  public String send(long id, String prompt) {
    AgentClient client = get(id);
    client.query(prompt);
    ResultMessage result = client.awaitResult(null);
    return result.getResult();
  }

  /** 回合进行中也可以调用。 */
  public Kv status(long id) {
    AgentClient client = get(id);
    return Kv.by("mcpServers", client.mcpStatus()).set("contextUsage", Kv.create().set(client.getContextUsage()));
  }

  public void interrupt(long id) {
    get(id).interrupt();
  }

  public void close(long id) {
    AgentClient client = sessions.remove(id);
    if (client != null) {
      client.close();
    }
  }

  public void closeAll() {
    for (Long id : sessions.keySet()) {
      close(id);
    }
  }

  private AgentClient get(long id) {
    AgentClient client = sessions.get(id);
    if (client == null) {
      throw new ParameterValidationException("Unknown session: " + id);
    }
    return client;
  }
}

每个会话对应一个 CLI 进程,服务关闭时一并关闭:

java
import nexus.io.agent.demo.service.AgentSessionService;
import nexus.io.hook.HookCan;
import nexus.io.jfinal.aop.Aop;

HookCan.me().addDestroyMethod(() -> Aop.get(AgentSessionService.class).closeAll());

注册路由、拦截器与异常处理 ​

java
package nexus.io.agent.demo.config;

import nexus.io.agent.demo.exception.GlobalExceptionHandler;
import nexus.io.agent.demo.handler.AgentQueryHandler;
import nexus.io.agent.demo.handler.AgentStreamHandler;
import nexus.io.agent.demo.interceptor.ApiTokenInterceptor;
import nexus.io.context.BootConfiguration;
import nexus.io.tio.boot.http.interceptor.HttpInteceptorConfigure;
import nexus.io.tio.boot.http.interceptor.HttpInterceptorModel;
import nexus.io.tio.boot.server.TioBootServer;
import nexus.io.tio.http.server.router.HttpRequestRouter;

public class AgentDemoConfig implements BootConfiguration {

  @Override
  public void config() {
    TioBootServer server = TioBootServer.me();
    HttpRequestRouter router = server.getRequestRouter();
    router.post("/api/agent/query", new AgentQueryHandler()::handle);
    router.post("/api/agent/stream", new AgentStreamHandler()::handle);

    HttpInterceptorModel token = new HttpInterceptorModel();
    token.setName("apiToken");
    token.addBlockUrl("/api/**");
    token.setInterceptor(new ApiTokenInterceptor());
    HttpInteceptorConfigure interceptors = new HttpInteceptorConfigure();
    interceptors.add(token);
    server.setHttpInteceptorConfigure(interceptors);

    server.setExceptionHandler(new GlobalExceptionHandler());
  }
}

Token 拦截器:

java
package nexus.io.agent.demo.interceptor;

import nexus.io.model.body.RespBodyVo;
import nexus.io.tio.http.common.HttpRequest;
import nexus.io.tio.http.common.HttpResponse;
import nexus.io.tio.http.common.RequestLine;
import nexus.io.tio.http.server.intf.HttpRequestInterceptor;
import nexus.io.tio.utils.environment.EnvUtils;

public class ApiTokenInterceptor implements HttpRequestInterceptor {

  @Override
  public HttpResponse doBeforeHandler(HttpRequest request, RequestLine requestLine, HttpResponse response) {
    String expected = EnvUtils.getStr("app.api.token");
    if (expected == null || expected.isEmpty()) {
      return EnvUtils.isDev() ? null : response.setStatus(401).setJson(RespBodyVo.fail("app.api.token is not configured"));
    }
    if (!expected.equals(request.getBearerToken())) {
      return response.setStatus(401).setJson(RespBodyVo.fail("Invalid or missing bearer token"));
    }
    return null;
  }

  @Override
  public void doAfterHandler(HttpRequest request, RequestLine requestLine, HttpResponse response, long cost) {
  }
}

全局异常处理器把参数错误转换为 400,Agent 通信错误转换为 502:

java
package nexus.io.agent.demo.exception;

import nexus.io.agent.sdk.AgentException;
import nexus.io.model.body.RespBodyVo;
import nexus.io.model.exception.ParameterValidationException;
import nexus.io.tio.boot.exception.TioBootExceptionHandler;
import nexus.io.tio.boot.http.TioRequestContext;
import nexus.io.tio.core.ChannelContext;
import nexus.io.tio.http.common.HttpRequest;
import nexus.io.tio.websocket.common.WebSocketRequest;

public class GlobalExceptionHandler implements TioBootExceptionHandler {

  @Override
  public RespBodyVo handler(HttpRequest request, Throwable e) {
    if (e instanceof ParameterValidationException) {
      TioRequestContext.getResponse().setStatus(400);
      return RespBodyVo.fail(e.getMessage());
    }
    if (e instanceof AgentException) {
      TioRequestContext.getResponse().setStatus(502);
      return RespBodyVo.fail("Agent error: " + e.getMessage());
    }
    TioRequestContext.getResponse().setStatus(500);
    return RespBodyVo.fail("Internal server error");
  }

  @Override
  public Object wsTextHandler(WebSocketRequest webSocketRequest, String text, ChannelContext channelContext,
      HttpRequest httpRequest, Throwable e) {
    return null;
  }

  @Override
  public Object wsBytesHandler(WebSocketRequest webSocketRequest, byte[] bytes, ChannelContext channelContext,
      HttpRequest httpRequest, Throwable e) {
    return null;
  }
}

启动类:

java
package nexus.io.agent.demo;

import nexus.io.agent.demo.config.AgentDemoConfig;
import nexus.io.tio.boot.TioApplication;

public class AgentDemoApp {
  public static void main(String[] args) {
    TioApplication.run(AgentDemoApp.class, new AgentDemoConfig(), args);
  }
}

小结 ​

  • tio-boot 负责 HTTP、鉴权、校验和 SSE,SDK 负责与 Agent CLI 的协议细节,业务代码只处理类型化的消息。
  • 工具权限、MCP 工具都在应用进程内实现,可以直接复用应用的 service 和配置。
  • 一次性查询适合任务型接口,AgentClient 会话适合多轮对话;两者都能在回合进行中查询状态和中断。

MIT Licensed