harness - 实战(SolonCode CLI)
2026年4月3日 下午2:01:56
具体参考:SolonCode CLI 的源码(同时也有 soloncode-web, soloncode-desktop 源码可参考)
1、扩展一个新的属性 AgentProperties
@Getter
@Setter
public class AgentProperties extends HarnessProperties {
public final static String OPENCODE_SKILLS = ".opencode/skills/";
public final static String CLAUDE_SKILLS = ".claude/skills/";
private String uiType = "old";
private boolean thinkPrinted = false;
private boolean cliEnabled = true;
private boolean cliPrintSimplified = true;
private boolean webEnabled = false;
private String webEndpoint = "/cli";
private boolean acpEnabled = false;
private String acpTransport = "stdio";
private String acpEndpoint = "/acp";
private boolean wsEnabled = true;
private String wsEndpoint = "/ws";
public AgentProperties() {
super(".soloncode/");
getSkillPools().put("@opencode_skills", Paths.get(getWorkspace(), OPENCODE_SKILLS).toString());
getSkillPools().put("@claude_skills", Paths.get(getWorkspace(), CLAUDE_SKILLS).toString());
}
}
2、定制应用启动类 App
public class App {
public static void main(String[] args) {
Solon.start(App.class, args, app -> {
//加载配置文件
AgentProperties c = new AgentProperties();
URL configUrl = c.getConfigUrl();
app.cfg().loadAdd(configUrl);
//获取命令行运行的当前用户工作区
String workspace = Paths.get(AgentProperties.getUserDir()).toAbsolutePath().normalize().toString();
app.cfg().getProp("soloncode").bindTo( c);
c.setWorkspace(workspace);
app.context().wrapAndPut(AgentProperties.class, c);
app.enableHttp(false); //默认不启用 http
if (c.isWebEnabled()) {
app.enableHttp(true);
}
if (c.isAcpEnabled() && "stdio".equals(c.getAcpTransport()) == false) {
app.enableHttp(true);
app.enableWebSocket(true);
}
if (c.isWsEnabled()) {
app.enableWebSocket(true);
}
});
AgentProperties agentProps = Solon.context().getBean(AgentProperties.class);
if (agentProps == null || agentProps.getChatModel() == null) {
throw new RuntimeException("ChatModel config not found");
}
ChatModel chatModel = ChatModel.of(agentProps.getChatModel()).build();
Map<String, AgentSession> sessionMap = new ConcurrentHashMap<>();
// 会话数据存到全局目录 ~/.soloncode/sessions/<sessionId>/
AgentSessionProvider sessionProvider = (sessionId) -> sessionMap.computeIfAbsent(sessionId, key ->
new FileAgentSession(key, Paths.get(agentProps.getWorkspace(), agentProps.getHarnessSessions()).resolve(key).normalize().toFile().toString()));
HarnessEngine agentRuntime = HarnessEngine.builder()
.chatModel(chatModel)
.properties(agentProps)
.sessionProvider(sessionProvider)
.build();
//flag
if(Solon.cfg().argx().flags().size() > 0){
String flag = Solon.cfg().argx().flagAt(0);
if ("run".equals(flag)) {
//单次任务态
String prompt = Solon.cfg().argx().flagAt(1);
new CliShellOld(agentRuntime, agentProps).call(prompt);
Solon.stop();
return;
}
//未来可以支持更多控制标记
}
//cli
if (agentProps.isCliEnabled()) {
if ("new".equals(agentProps.getUiType())) {
new Thread(new CliShellNew(agentRuntime, agentProps), "CLI-Interactive-Thread").start();
} else {
new Thread(new CliShellOld(agentRuntime, agentProps), "CLI-Interactive-Thread").start();
}
}
//web
if (agentProps.isWebEnabled()) {
Solon.app().router().get(agentProps.getWebEndpoint(), new WebGate(agentRuntime));
}
//acp
if (agentProps.isAcpEnabled()) {
AcpAgentTransport agentTransport;
if ("stdio".equals(agentProps.getAcpTransport())) {
agentTransport = new StdioAcpAgentTransport();
} else {
agentTransport = new WebSocketSolonAcpAgentTransport(
agentProps.getAcpTransport(), McpJsonMapper.getDefault());
}
new AcpLink(agentRuntime, agentTransport).run();
}
if (agentProps.isWsEnabled()){
WebSocketRouter.getInstance().of("ws", new WebSocketGate(agentRuntime));
}
}
}
3、定制简单的命令行界面
public class CliShellOld implements Runnable {
private final static Logger LOG = LoggerFactory.getLogger(CliShellOld.class);
private Terminal terminal;
private LineReader reader;
private final HarnessEngine agentRuntime;
private final AgentProperties agentProps;
// ANSI 颜色常量 - 严格对齐 Claude 极简风
private final static String
BOLD = "\033[1m",
DIM = "\033[2m",
GREEN = "\033[32m",
YELLOW = "\033[33m",
RED = "\033[31m",
CYAN = "\033[36m",
RESET = "\033[0m";
public CliShellOld(HarnessEngine agentRuntime, AgentProperties agentProps) {
this.agentRuntime = agentRuntime;
this.agentProps = agentProps;
try {
this.terminal = TerminalBuilder.builder()
.jna(true).jansi(true).system(true).dumb(true)
.encoding(StandardCharsets.UTF_8)
.build();
this.reader = LineReaderBuilder.builder()
.terminal(terminal)
.completer(new FileNameCompleter())
.build();
} catch (Throwable e) {
LOG.error("JLine initialization failed", e);
}
}
/**
* 预备开始
*/
private AgentSession prepare(String sessionId) {
// Windows 下将控制台切换为 UTF-8 代码页,避免中文输入乱码
if (System.getProperty("os.name").toLowerCase().contains("win")) {
try {
Process process = new ProcessBuilder("cmd", "/c", "chcp", "65001").start();
// 读取并丢弃输出,避免显示到控制台
try (java.io.InputStream is = process.getInputStream()) {
while (is.read() != -1) {}
}
process.waitFor();
} catch (Exception ignored) {
}
}
printWelcome();
return agentRuntime.getSession(sessionId);
}
/**
* 单次调用
*/
public void call(String input) {
AgentSession session = prepare(HarnessEngine.SESSION_DEFAULT);
try {
if (!isSystemCommand(session, input)) {
performAgentTask(session, input);
}
} catch (Throwable e) {
terminal.writer().println("\n" + RED + "! Error: " + RESET + e.getMessage());
}
}
/**
* 长运行
*/
@Override
public void run() {
AgentSession session = prepare(HarnessEngine.SESSION_DEFAULT);
// 2. 主循环
while (true) {
try {
String input;
try {
terminal.writer().println();
terminal.writer().print(BOLD + CYAN + "User" + RESET);
terminal.writer().println();
terminal.flush();
input = reader.readLine(BOLD + CYAN + "> " + RESET);
} catch (UserInterruptException e) {
continue;
} catch (EndOfFileException e) {
break;
}
if (Assert.isEmpty(input)) continue;
if (!isSystemCommand(session, input)) {
performAgentTask(session, input);
}
} catch (Throwable e) {
terminal.writer().println("\n" + RED + "! Error: " + RESET + e.getMessage());
}
}
}
private boolean isSystemCommand(AgentSession session, String input) throws Exception {
String cmd = input.trim().toLowerCase();
if ("/exit".equals(cmd)) {
terminal.writer().println(DIM + "Exiting..." + RESET);
System.exit(0);
return true;
}
if ("/resume".equals(cmd)) {
performAgentTask(session, null);
return true;
}
if ("/clear".equals(cmd)) {
session.clear();
printWelcome(); // 推荐加上,让用户清屏后不至于面对一个完全的黑洞
return true;
}
return false;
}
private void performAgentTask(AgentSession session, String input) throws Exception {
terminal.writer().println("\n" + BOLD + "Assistant" + RESET);
String currentInput = input;
final AtomicBoolean isTaskCompleted = new AtomicBoolean(false);
final AtomicBoolean isFirstConversation = new AtomicBoolean(true);
while (true) {
// 简化状态提示:只在非首次且任务未完成时打印等待符
if (currentInput == null && !isTaskCompleted.get()) {
terminal.writer().print("\r" + DIM + " ... " + RESET);
terminal.flush();
}
CountDownLatch latch = new CountDownLatch(1);
final AtomicBoolean isInterrupted = new AtomicBoolean(false);
final AtomicBoolean isFirstReasonChunk = new AtomicBoolean(true);
Prompt prompt = Prompt.of(currentInput).attrPut("start_time", System.currentTimeMillis());
Disposable disposable = agentRuntime.getMainAgent()
.prompt(prompt)
.session(session)
.stream()
.subscribeOn(Schedulers.boundedElastic())
.doOnNext(chunk -> {
if (chunk instanceof ReasonChunk) {
// ReasonChunk 非工具调用时,为流式增量(工具调用时为全量,不需要打印)
onReasonChunk((ReasonChunk) chunk, isFirstReasonChunk, isFirstConversation);
} else if (chunk instanceof ThoughtChunk) {
//ThoughtChunk 为全量(ReasonChunk 的汇总)
onThoughtChunk((ThoughtChunk) chunk);
} else if (chunk instanceof ActionEndChunk) {
//ActionEndChunk 为全量,一次工具调用一个 ActionEndChunk
onActionEndChunk((ActionEndChunk) chunk, isFirstReasonChunk);
} else if (chunk instanceof ReActChunk) {
// ReActChunk 为全量,ReAct 完成任务时的最后答复
onFinalChunk((ReActChunk) chunk, isFirstReasonChunk, isFirstConversation);
}
})
.doOnError(e -> {
terminal.writer().println("\n" + RED + "── Error ────────────────" + RESET);
terminal.writer().println(e.getMessage());
terminal.flush();
})
.doFinally(signal -> {
isTaskCompleted.set(true);
latch.countDown();
})
.subscribe();
// 监听回车中断
if (disposable == null || disposable.isDisposed()) {
// 处理订阅失败的情况
return;
}
waitForTask(latch, disposable, session, isInterrupted);
if (isInterrupted.get()) {
terminal.writer().println(DIM + "[Task interrupted]" + RESET);
terminal.flush();
session.addMessage(ChatMessage.ofAssistant("用户中途取消了这个任务."));
return;
}
// HITL 处理 (授权交互)
if (HITL.isHitl(session)) {
if (handleHITL(session)) {
currentInput = null;
continue;
} else {
return;
}
}
if (isTaskCompleted.get()) {
terminal.writer().println();
terminal.flush();
return;
}
currentInput = null;
}
}
private void waitForTask(CountDownLatch latch, Disposable disposable,
AgentSession session, AtomicBoolean isInterrupted) throws Exception {
Attributes originalAttributes = terminal.getAttributes();
try {
terminal.enterRawMode();
while (latch.getCount() > 0) {
int c = terminal.reader().read(50);
if (c == 27 || c == '\r' || c == '\n') {
disposable.dispose();
isInterrupted.set(true);
latch.countDown();
break;
}
if (HITL.isHitl(session)) {
latch.countDown();
break;
}
}
} finally {
terminal.setAttributes(originalAttributes);
}
latch.await();
}
private boolean handleHITL(AgentSession session) {
HITLTask task = HITL.getPendingTask(session);
HITLDecision decision = HITL.getDecision(session, task);
if (decision != null) {
if (decision.isRejected()) {
return false;
} else {
return true;
}
}
terminal.writer().println("\n" + BOLD + YELLOW + "Permission Required" + RESET);
if ("bash".equals(task.getToolName())) {
terminal.writer().println(DIM + "Command: " + RESET + task.getArgs().get("command"));
}
String choice = reader.readLine(BOLD + GREEN + "Approve? (y/n) " + RESET).trim().toLowerCase();
if ("y".equals(choice) || "yes".equals(choice)) {
HITL.approve(session, task.getToolName());
return true;
} else {
HITL.reject(session, task.getToolName());
terminal.writer().println(DIM + "Action rejected." + RESET);
return false;
}
}
private void onFinalChunk(ReActChunk react, AtomicBoolean isFirstReasonChunk, AtomicBoolean isFirstConversation) {
if (react.isNormal() == false) {
String delta = clearThink(react.getContent());
onReasonChunkDo(delta, isFirstReasonChunk, isFirstConversation);
}
Long start_time = react.getTrace().getOriginalPrompt().attrAs("start_time");
StringBuilder buf = new StringBuilder();
buf.append(" (");
if (react.getTrace().getMetrics() != null) {
buf.append(react.getTrace().getMetrics().getTotalTokens()).append(" tokens");
}
if (start_time != null) {
long seconds = Duration.ofMillis(System.currentTimeMillis() - start_time).getSeconds();
if (buf.length() > 2) {
buf.append(", ");
}
buf.append(seconds).append(" seconds");
}
buf.append(")");
if (buf.length() > 4) {
terminal.writer().println(DIM + buf + RESET);
}
}
private void onReasonChunk(ReasonChunk reason, AtomicBoolean isFirstReasonChunk, AtomicBoolean isFirstConversation) {
if (!reason.isToolCalls() && reason.hasContent()) {
//打印 think 或者 不是 think
if (agentProps.isThinkPrinted() || !reason.getMessage().isThinking()) {
String delta = clearThink(reason.getContent());
onReasonChunkDo(delta, isFirstReasonChunk, isFirstConversation);
}
}
}
private void onReasonChunkDo(String delta, AtomicBoolean isFirstReasonChunk, AtomicBoolean isFirstConversation) {
if (Assert.isNotEmpty(delta)) {
if (isFirstReasonChunk.get()) {
String trimmed = delta.replaceAll("^[\\s\\n]+", "");
if (Assert.isNotEmpty(trimmed)) {
if (isFirstConversation.get()) {
terminal.writer().print(" ");
isFirstConversation.set(false);
} else {
terminal.writer().print("\n ");
}
terminal.writer().print(trimmed.replace("\n", "\n "));
isFirstReasonChunk.set(false);
}
} else {
// 连续的思考内容,保持缩进替换即可
terminal.writer().print(delta.replace("\n", "\n "));
}
terminal.flush();
}
}
private void onThoughtChunk(ThoughtChunk thought) {
if (thought.hasMeta(TaskSkill.TOOL_MULTITASK)) {
// 仅在多任务并行且有内容时输出
String content = thought.getAssistantMessage().getResultContent();
if (Assert.isNotEmpty(content)) {
// 保持 Claude 风格的间接缩进,去掉首尾多余换行
terminal.writer().println();
terminal.writer().print(" " + content.trim().replace("\n", "\n "));
terminal.writer().println();
terminal.flush();
}
}
}
private void onActionEndChunk(ActionEndChunk action, AtomicBoolean isFirstReasonChunk) {
if (Assert.isNotEmpty(action.getToolName())) {
if (TaskSkill.TOOL_MULTITASK.equals(action.getToolName()) ||
TaskSkill.TOOL_TASK.equals(action.getToolName())) {
return;
}
final String fullToolName;
if (agentRuntime.getName().equals(action.getAgentName())) {
fullToolName = action.getToolName();
} else {
fullToolName = action.getAgentName() + "/" + action.getToolName();
}
// 1. 准备参数字符串
StringBuilder argsBuilder = new StringBuilder();
Map<String, Object> args = action.getArgs();
if (args != null && !args.isEmpty()) {
args.forEach((k, v) -> {
if (argsBuilder.length() > 0) argsBuilder.append(" ");
argsBuilder.append(k).append("=").append(v);
});
}
String argsStr = argsBuilder.toString().replace("\n", " ");
boolean hasBigArgs = argsStr.length() > 100 || (args != null && args.values().stream().anyMatch(v -> v instanceof String && ((String) v).contains("\n")));
if (agentProps.isCliPrintSimplified()) {
// --- 简化风格:单行摘要模式 ---
String content = action.getContent() == null ? "" : action.getContent().trim();
String summary;
if (Assert.isEmpty(content)) {
summary = "completed";
} else {
String[] lines = content.split("\n");
if (lines.length > 1) {
summary = "returned " + lines.length + " lines";
} else {
summary = content.length() > 40 ? content.substring(0, 37) + "..." : content;
}
}
// 简化模式下,参数也进行极简压缩
String shortArgs = argsStr.length() > 40 ? argsStr.substring(0, 37) + "..." : argsStr;
terminal.writer().println();
terminal.writer().println(YELLOW + "❯ " + RESET + BOLD + fullToolName + RESET + " " + DIM + shortArgs + " (" + summary + ")" + RESET);
terminal.flush();
} else {
// --- 全量风格 ---
// 1. 打印指令行
terminal.writer().println();
if (!hasBigArgs) {
// 短参数直接跟在后面
terminal.writer().println(YELLOW + "❯ " + RESET + BOLD + fullToolName + RESET + " " + DIM + argsStr + RESET);
} else {
// 大参数块,指令名独占一行,参数作为缩进内容打印(类似 write_file 的 content 部分)
terminal.writer().println(YELLOW + "❯ " + RESET + BOLD + fullToolName + RESET);
if (args != null) {
args.forEach((k, v) -> {
String val = String.valueOf(v).trim();
if ("content".equals(k) && val.split("\n").length > 10) {
// 如果是写文件,且内容太长,只显示头尾
String[] lines = val.split("\n");
val = lines[0] + "\n ...\n " + lines[lines.length - 1];
}
terminal.writer().println(DIM + " [" + k + "]: " + val.replace("\n", "\n ") + RESET);
});
}
}
// 2. 处理工具返回的结果内容 (getContent)
if (Assert.isNotEmpty(action.getContent())) {
// 在参数和结果之间如果内容较多,可以加个小分隔,或者直接缩进打印
String indentedContent = " " + action.getContent().trim().replace("\n", "\n ");
terminal.writer().println(DIM + indentedContent + RESET);
}
terminal.writer().println(DIM + " (End of output)" + RESET);
terminal.flush();
}
// 3. 接下来 AI 可能会针对这个结果进行分析 (Reasoning),设置首行缩进标记
isFirstReasonChunk.set(true);
}
}
private String clearThink(String chunk) {
return chunk.replaceAll("(?s)<\\s*/?think\\s*>", "");
}
protected void printWelcome() {
String path = new File(agentRuntime.getProps().getWorkspace()).getAbsolutePath();
// 连带版本号,紧凑排列
terminal.writer().println(BOLD + "SolonCode" + RESET + DIM + " " + agentRuntime.getVersion() + " PID-" + Utils.pid() + RESET);
terminal.writer().println(DIM + path + RESET);
terminal.writer().print(DIM + "Tips: " + RESET + "(esc)" + DIM + " interrupt | " +
RESET + "'/exit'" + DIM + ": quit | " +
RESET + "'/resume'" + DIM + ": resume | " +
RESET + "'/clear'" + DIM + ": reset" + RESET);
//terminal.writer().println(DIM + "Commands: " + RESET + "exit" + DIM + ", " + RESET + "init (code)" + DIM + ", " + RESET + "clear (session)" + RESET);
// 仅保留一个空行
terminal.writer().println();
terminal.flush();
}
}