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,可执行文件路径由配置给出。
添加依赖
<dependency>
<groupId>nexus.io</groupId>
<artifactId>agent-cli-sdk</artifactId>
<version>${agent-cli-sdk.version}</version>
</dependency>
配置
app.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 只负责参数和响应。
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);
}
}
请求实体:
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 在每次需要确认的工具调用时执行,服务端可以在这里落实统一的安全策略。
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。
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:
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:
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));
}
}
返回示例:
{"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 响应头后立即返回。
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;
}
}
测试:
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:
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 进程,服务关闭时一并关闭:
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());
注册路由、拦截器与异常处理
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 拦截器:
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:
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;
}
}
启动类:
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会话适合多轮对话;两者都能在回合进行中查询状态和中断。
