diff --git a/AGENTS.md b/AGENTS.md
index 00bb71d3b00e36822ef3aaa22fedc02c1e279df2..9a9e20320ed89a045c537b80bf31ac4ba65e844e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -212,7 +212,6 @@
- `read_file`
- `write_file`
- `edit_file`
-- `exec_command`
- `notify_user`
- `spawn_task`
- `list_child_runs`
@@ -228,8 +227,6 @@
工作区工具边界:
- 文件读写路径必须在工作区内
-- `exec_command` 也在工作区目录下执行
-- 命令执行默认 30 秒超时
- 工具输出会截断,避免模型上下文过大
### 2. CLI 技能
@@ -238,6 +235,15 @@
- 技能池名:`@skills`
- 实际目录:`./workspace/skills`
+- `TerminalSkill` 的 `bash` 能力默认启用沙盒模式,可通过 `solonclaw.agent.tools.sandboxMode` 配置开关
+
+当前 `sandboxMode` 行为边界:
+
+- `true`:`bash` / `ls` / `read` / `grep` / `glob` 等 CLI 能力只允许工作区相对路径、`~/` 和 `@skills` 这类逻辑路径
+- `true`:禁止在命令或路径参数中直接使用绝对路径,也禁止通过相对路径越出工作区
+- `true`:`@skills` 这类逻辑路径仍可读、可执行,但保持只读,不能写入
+- `false`:允许绝对路径访问,CLI 能力会进入更开放模式
+- 无论开关如何,`@skills` 逻辑路径始终是只读挂载池
协作规则:
@@ -400,6 +406,7 @@
- `solonclaw.workspace=./workspace`
- `solonclaw.agent.scheduler.maxConcurrentPerConversation=4`
- `solonclaw.agent.scheduler.ackWhenBusy=false`
+- `solonclaw.agent.tools.sandboxMode=true`
- `solonclaw.agent.heartbeat.enabled=true`
- `solonclaw.agent.heartbeat.intervalSeconds=1800`
- `solonclaw.channels.dingtalk.*`
diff --git a/README.en.md b/README.en.md
index 7b2cd8f0d3655b7b2650f3aba369d4d2fbc7bfb9..f692309f3f99aa34bedd294603d2cfdc949c4a65 100644
--- a/README.en.md
+++ b/README.en.md
@@ -13,7 +13,7 @@
- 🤖 Unified Agent runtime
- 💬 Shared runtime for Debug Web and DingTalk
- 🧠 Workspace-driven prompt assembly and memory files
-- 🛠️ Built-in tools for file IO, command execution, notifications, and job management
+- 🛠️ Built-in tools for file IO, notifications, job management, and CLI terminal skills
- 🧩 Child task spawning with continuation back to the parent conversation
- ⏰ Persistent scheduled jobs restored on startup
- 📁 File-based runtime storage for runs, conversations, dedup, routes, and media
@@ -54,7 +54,6 @@ Built-in tools currently include:
- `read_file`
- `write_file`
- `edit_file`
-- `exec_command`
- `notify_user`
- `spawn_task`
- `list_child_runs`
@@ -70,11 +69,19 @@ Built-in tools currently include:
Behavioral notes:
- File access is restricted to the configured workspace.
-- Commands are executed inside the workspace directory.
+- Command execution now goes through CLI `TerminalSkill` via `bash`.
+- `TerminalSkill` sandbox mode is enabled by default and can be controlled with `solonclaw.agent.tools.sandboxMode`.
- Child runs use independent session keys and can be aggregated by `batchKey`.
- Scheduled jobs are bound to the latest external reply route.
- Heartbeat checks read `HEARTBEAT.md` and trigger a silent internal run.
+`sandboxMode` rules:
+
+- `true`: CLI abilities such as `bash`, `ls`, `read`, `grep`, and `glob` only allow workspace-relative paths, `~/`, and logical pool paths like `@skills`
+- `true`: absolute paths are blocked, and relative traversal outside the workspace is blocked
+- `true`: logical pool paths such as `@skills` remain readable/executable but are still read-only
+- `false`: absolute-path access is allowed and the CLI becomes more open
+
## Workspace Layout
Default workspace root:
@@ -240,6 +247,7 @@ Current important settings:
- `solonclaw.workspace=./workspace`
- `solonclaw.agent.scheduler.maxConcurrentPerConversation=4`
- `solonclaw.agent.scheduler.ackWhenBusy=false`
+- `solonclaw.agent.tools.sandboxMode=true`
- `solonclaw.agent.heartbeat.enabled=true`
- `solonclaw.agent.heartbeat.intervalSeconds=1800`
- `solonclaw.channels.dingtalk.*`
diff --git a/README.md b/README.md
index 449fbff69a0872958cf2b7f9f7166e66df423e76..528c0cf797f3ae3f7929344eedc237b6894d2c5e 100644
--- a/README.md
+++ b/README.md
@@ -13,7 +13,7 @@
- 🤖 构建一个可持续运行的个人/团队 AI 助手
- 💬 同时接本地 Debug Web 与钉钉机器人
- 🧠 让 Agent 基于工作区文件获得记忆、身份和行为约束
-- 🛠️ 让 Agent 通过工具读写工作区、执行命令、管理任务
+- 🛠️ 让 Agent 通过工具读写工作区、通过 CLI 技能执行命令、管理任务
- 🧩 把复杂问题拆成多个子任务并回流父会话
- ⏰ 创建持久化定时任务,让 Agent 定期执行工作
@@ -35,7 +35,7 @@
`AGENTS.md / SOUL.md / IDENTITY.md / USER.md / TOOLS.md / HEARTBEAT.md / MEMORY.md / memory/YYYY-MM-DD.md` 会自动参与系统提示词拼装。
- 🧰 内置工具与技能
- 支持文件读写、片段编辑、命令执行、任务查询、定时任务管理,并可从 `workspace/skills` 挂载 CLI 技能池 `@skills`。
+ 支持文件读写、片段编辑、任务查询、定时任务管理,并可从 `workspace/skills` 挂载 CLI 技能池 `@skills`。
- 🧪 本地调试友好
自带 Debug Web 页面,可直接查看 run 状态、流式事件、子任务列表与聚合摘要。
@@ -167,7 +167,6 @@ workspace/
- `read_file`
- `write_file`
- `edit_file`
-- `exec_command`
- `notify_user`
- `spawn_task`
- `list_child_runs`
@@ -183,10 +182,18 @@ workspace/
能力特点:
- 文件读写受工作区边界保护
-- 命令执行默认在工作区目录进行
+- 命令执行走 CLI `TerminalSkill` 的 `bash` 能力
+- `TerminalSkill` 默认启用沙盒模式,可通过 `solonclaw.agent.tools.sandboxMode` 控制
- 定时任务会绑定最近一次外部会话路由
- 心跳检查会读取 `HEARTBEAT.md` 并触发静默内部运行
+`sandboxMode` 规则:
+
+- `true`:CLI 的 `bash` / `ls` / `read` / `grep` / `glob` 等能力只允许工作区相对路径、`~/` 和 `@skills` 逻辑路径
+- `true`:禁止绝对路径,禁止通过 `../` 等方式越出工作区
+- `true`:`@skills` 逻辑路径可读、可执行,但仍是只读挂载池
+- `false`:放开绝对路径访问,CLI 能力进入更开放模式
+
## 快速开始
### 1. 环境要求
@@ -302,6 +309,7 @@ java ${JAVA_OPTS} \
- `solonclaw.workspace=./workspace`
- `solonclaw.agent.scheduler.maxConcurrentPerConversation=4`
- `solonclaw.agent.scheduler.ackWhenBusy=false`
+- `solonclaw.agent.tools.sandboxMode=true`
- `solonclaw.agent.heartbeat.enabled=true`
- `solonclaw.agent.heartbeat.intervalSeconds=1800`
- `solonclaw.channels.dingtalk.*`
diff --git a/pom.xml b/pom.xml
index 2689908afd75315443ba05ee6f4ef788d847ca98..d803e2ab2c58de160199ff092c6be74a2cf054e8 100644
--- a/pom.xml
+++ b/pom.xml
@@ -67,6 +67,12 @@
1.1.0
+
+ com.larksuite.oapi
+ oapi-sdk
+ 2.4.0
+
+
com.aliyun
dingtalk
diff --git a/scripts/config.example.yml b/scripts/config.example.yml
index c2f2e0310d7e50a135671033ed546704c6fc0b83..57ca808fe3506c462d0da0fa74d7244a635fd0e9 100644
--- a/scripts/config.example.yml
+++ b/scripts/config.example.yml
@@ -137,6 +137,7 @@ solonclaw:
## 工作区
- 工作区是默认文件根目录;除非用户明确要求,不要把运行期文件写到别处。
- 用户可编辑的工作区文件会在后文注入;如果存在 AGENTS.md、SOUL.md、USER.md、TOOLS.md、HEARTBEAT.md 等内容,应把它们视为当前运行的重要上下文。
+ - 命令执行过程中产生的临时文件、下载缓存和中间产物,优先放入临时目录;任务结束后应清理无用临时文件,避免污染工作区。
## 心跳
- 如果收到心跳检查且当前没有需要处理的事项,就简洁确认状态正常。
@@ -145,12 +146,38 @@ solonclaw:
scheduler:
maxConcurrentPerConversation: 4
ackWhenBusy: false
+ tools:
+ # 是否为 CLI TerminalSkill 开启沙盒模式
+ # true: 只允许工作区相对路径、~/ 和 @skills 逻辑路径;禁止绝对路径与越界访问
+ # false: 允许绝对路径,CLI 能力更开放
+ # 说明:@skills 这类逻辑路径始终是只读挂载池,开关不会放开写入
+ sandboxMode: true
heartbeat:
enabled: true
intervalSeconds: 1800
channels:
+ feishu:
+ # 不使用飞书时保持 false
+ enabled: false
+
+ # 飞书机器人配置
+ appId: "cli_your_app_id"
+ appSecret: "your-app-secret"
+ baseDomain: "https://open.feishu.cn"
+
+ # 是否启用基于卡片 patch 的流式更新
+ streamingReply: true
+
+ # 私聊白名单:为空时当前代码行为是默认允许
+ # 示例:["ou_xxx", "ou_yyy"]
+ allowFrom: []
+
+ # 群聊白名单:为空时当前代码行为是默认允许
+ # 示例:["oc_xxx", "oc_yyy"]
+ groupAllowFrom: []
+
dingtalk:
# 不使用钉钉时保持 false
enabled: false
diff --git a/src/main/java/com/jimuqu/claw/agent/channel/ChannelAdapter.java b/src/main/java/com/jimuqu/claw/agent/channel/ChannelAdapter.java
index 3c8426663116773a56b5b15b6ad0656df7e43d27..b11fb2d9bfae79cf8dd00b81b20c91d6e1276f72 100644
--- a/src/main/java/com/jimuqu/claw/agent/channel/ChannelAdapter.java
+++ b/src/main/java/com/jimuqu/claw/agent/channel/ChannelAdapter.java
@@ -14,6 +14,15 @@ public interface ChannelAdapter {
*/
ChannelType channelType();
+ /**
+ * 当前渠道是否支持将运行中的增量内容作为“进度更新”透传到外部。
+ *
+ * @return 若支持进度更新则返回 true
+ */
+ default boolean supportsProgressUpdates() {
+ return false;
+ }
+
/**
* 发送一条出站消息。
*
diff --git a/src/main/java/com/jimuqu/claw/agent/model/ChannelType.java b/src/main/java/com/jimuqu/claw/agent/model/ChannelType.java
index 9594baf6375bcbd41d6f5de07156daae1a8d7909..f3185c0a11de8f56844bb6dfe8cc1a12a35ad594 100644
--- a/src/main/java/com/jimuqu/claw/agent/model/ChannelType.java
+++ b/src/main/java/com/jimuqu/claw/agent/model/ChannelType.java
@@ -6,6 +6,8 @@ package com.jimuqu.claw.agent.model;
public enum ChannelType {
/** 浏览器调试页渠道。 */
DEBUG_WEB,
+ /** 飞书机器人渠道。 */
+ FEISHU,
/** 钉钉机器人渠道。 */
DINGTALK,
/** 系统内部触发渠道。 */
diff --git a/src/main/java/com/jimuqu/claw/agent/model/InboundEnvelope.java b/src/main/java/com/jimuqu/claw/agent/model/InboundEnvelope.java
index 0c9cd1cf3f6d205ee9787bbd85397feded5c6630..035b7f349a45c1ae0c4792ec7ef13967edc2710e 100644
--- a/src/main/java/com/jimuqu/claw/agent/model/InboundEnvelope.java
+++ b/src/main/java/com/jimuqu/claw/agent/model/InboundEnvelope.java
@@ -31,6 +31,10 @@ public class InboundEnvelope {
private String sessionKey;
/** 会话事件版本号。 */
private long sessionVersion;
+ /** 当前入站触发类型。 */
+ private InboundTriggerType triggerType = InboundTriggerType.USER;
+ /** 当前运行关联到的历史锚点版本。 */
+ private long historyAnchorVersion;
/** 是否允许将最终回复回发到外部渠道。 */
private boolean externalReplyEnabled = true;
/** 是否将当前入站消息写入会话历史。 */
@@ -254,6 +258,42 @@ public class InboundEnvelope {
this.sessionVersion = sessionVersion;
}
+ /**
+ * 返回当前入站触发类型。
+ *
+ * @return 入站触发类型
+ */
+ public InboundTriggerType getTriggerType() {
+ return triggerType;
+ }
+
+ /**
+ * 设置当前入站触发类型。
+ *
+ * @param triggerType 入站触发类型
+ */
+ public void setTriggerType(InboundTriggerType triggerType) {
+ this.triggerType = triggerType;
+ }
+
+ /**
+ * 返回历史锚点版本。
+ *
+ * @return 历史锚点版本
+ */
+ public long getHistoryAnchorVersion() {
+ return historyAnchorVersion;
+ }
+
+ /**
+ * 设置历史锚点版本。
+ *
+ * @param historyAnchorVersion 历史锚点版本
+ */
+ public void setHistoryAnchorVersion(long historyAnchorVersion) {
+ this.historyAnchorVersion = historyAnchorVersion;
+ }
+
/**
* 返回是否允许外部回发。
*
diff --git a/src/main/java/com/jimuqu/claw/agent/model/InboundTriggerType.java b/src/main/java/com/jimuqu/claw/agent/model/InboundTriggerType.java
new file mode 100644
index 0000000000000000000000000000000000000000..4544c9c8806768bfffdcf79356cc3c5221543217
--- /dev/null
+++ b/src/main/java/com/jimuqu/claw/agent/model/InboundTriggerType.java
@@ -0,0 +1,13 @@
+package com.jimuqu.claw.agent.model;
+
+/**
+ * 描述一次入站触发在运行时中的语义类型。
+ */
+public enum InboundTriggerType {
+ /** 用户主动发起的普通消息。 */
+ USER,
+ /** 需要进入会话轨迹、但不应被视作用户发言的系统触发。 */
+ SYSTEM_VISIBLE,
+ /** 仅用于内部检查的静默系统触发。 */
+ SYSTEM_SILENT
+}
diff --git a/src/main/java/com/jimuqu/claw/agent/runtime/AgentRuntimeService.java b/src/main/java/com/jimuqu/claw/agent/runtime/AgentRuntimeService.java
index 631e211fc083b8e546196ca250f94e69d963c614..534e28b3d18adc227558ac1e5587878f5077ce93 100644
--- a/src/main/java/com/jimuqu/claw/agent/runtime/AgentRuntimeService.java
+++ b/src/main/java/com/jimuqu/claw/agent/runtime/AgentRuntimeService.java
@@ -2,11 +2,13 @@ package com.jimuqu.claw.agent.runtime;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.StrUtil;
+import com.jimuqu.claw.agent.channel.ChannelAdapter;
import com.jimuqu.claw.agent.channel.ChannelRegistry;
import com.jimuqu.claw.agent.model.AgentRun;
import com.jimuqu.claw.agent.model.ChannelType;
import com.jimuqu.claw.agent.model.ConversationType;
import com.jimuqu.claw.agent.model.InboundEnvelope;
+import com.jimuqu.claw.agent.model.InboundTriggerType;
import com.jimuqu.claw.agent.model.OutboundEnvelope;
import com.jimuqu.claw.agent.model.ReplyTarget;
import com.jimuqu.claw.agent.model.RunEvent;
@@ -81,6 +83,7 @@ public class AgentRuntimeService {
inboundEnvelope.setReceivedAt(System.currentTimeMillis());
inboundEnvelope.setSessionKey("debug-web:" + sessionId);
inboundEnvelope.setReplyTarget(new ReplyTarget(ChannelType.DEBUG_WEB, ConversationType.PRIVATE, sessionId, "debug-user"));
+ inboundEnvelope.setTriggerType(InboundTriggerType.USER);
return submitInbound(inboundEnvelope);
}
@@ -93,7 +96,19 @@ public class AgentRuntimeService {
* @return 运行任务标识
*/
public String submitSystemMessage(String sessionKey, ReplyTarget replyTarget, String content) {
- return submitSystemMessage(sessionKey, replyTarget, content, "system");
+ return submitVisibleSystemMessage(sessionKey, replyTarget, content, "system");
+ }
+
+ /**
+ * 向指定外部路由提交一条可见系统消息。
+ *
+ * @param sessionKey 会话键
+ * @param replyTarget 回复目标
+ * @param content 文本内容
+ * @return 运行任务标识
+ */
+ public String submitVisibleSystemMessage(String sessionKey, ReplyTarget replyTarget, String content) {
+ return submitVisibleSystemMessage(sessionKey, replyTarget, content, "system");
}
/**
@@ -118,7 +133,29 @@ public class AgentRuntimeService {
* @return 运行任务标识
*/
public String submitSystemMessage(String sessionKey, ReplyTarget replyTarget, String content, String senderId) {
- return submitSystemMessage(sessionKey, replyTarget, content, senderId, true, true, true);
+ return submitVisibleSystemMessage(sessionKey, replyTarget, content, senderId);
+ }
+
+ /**
+ * 向指定外部路由提交一条带自定义发送者的可见系统消息。
+ *
+ * @param sessionKey 会话键
+ * @param replyTarget 回复目标
+ * @param content 文本内容
+ * @param senderId 发送者标识
+ * @return 运行任务标识
+ */
+ public String submitVisibleSystemMessage(String sessionKey, ReplyTarget replyTarget, String content, String senderId) {
+ return submitSystemMessage(
+ sessionKey,
+ replyTarget,
+ content,
+ senderId,
+ InboundTriggerType.SYSTEM_VISIBLE,
+ true,
+ true,
+ true
+ );
}
/**
@@ -131,7 +168,16 @@ public class AgentRuntimeService {
* @return 运行任务标识
*/
public String submitSilentSystemMessage(String sessionKey, ReplyTarget replyTarget, String content, String senderId) {
- return submitSystemMessage(sessionKey, replyTarget, content, senderId, false, false, false);
+ return submitSystemMessage(
+ sessionKey,
+ replyTarget,
+ content,
+ senderId,
+ InboundTriggerType.SYSTEM_SILENT,
+ false,
+ false,
+ false
+ );
}
/**
@@ -151,6 +197,7 @@ public class AgentRuntimeService {
ReplyTarget replyTarget,
String content,
String senderId,
+ InboundTriggerType triggerType,
boolean externalReplyEnabled,
boolean persistInboundConversationEvent,
boolean persistAssistantConversationEvent
@@ -166,6 +213,7 @@ public class AgentRuntimeService {
inboundEnvelope.setReceivedAt(System.currentTimeMillis());
inboundEnvelope.setSessionKey(sessionKey);
inboundEnvelope.setReplyTarget(replyTarget);
+ inboundEnvelope.setTriggerType(triggerType);
inboundEnvelope.setExternalReplyEnabled(externalReplyEnabled);
inboundEnvelope.setPersistInboundConversationEvent(persistInboundConversationEvent);
inboundEnvelope.setPersistAssistantConversationEvent(persistAssistantConversationEvent);
@@ -190,9 +238,12 @@ public class AgentRuntimeService {
ConversationScheduler.SessionState state = conversationScheduler.inspect(inboundEnvelope.getSessionKey());
+ long latestConversationVersion = runtimeStoreService.getLatestConversationVersion(inboundEnvelope.getSessionKey());
+ long nextConversationVersion = latestConversationVersion + 1L;
+ inboundEnvelope.setHistoryAnchorVersion(resolveHistoryAnchorVersion(inboundEnvelope, nextConversationVersion));
long version = inboundEnvelope.isPersistInboundConversationEvent()
? runtimeStoreService.appendInboundConversationEvent(inboundEnvelope)
- : runtimeStoreService.getLatestConversationVersion(inboundEnvelope.getSessionKey()) + 1L;
+ : nextConversationVersion;
inboundEnvelope.setSessionVersion(version);
if (inboundEnvelope.getChannelType() != ChannelType.SYSTEM) {
runtimeStoreService.rememberReplyTarget(inboundEnvelope.getSessionKey(), inboundEnvelope.getReplyTarget());
@@ -209,7 +260,7 @@ public class AgentRuntimeService {
run.setRunId(runtimeStoreService.newRunId());
run.setSessionKey(inboundEnvelope.getSessionKey());
run.setSourceMessageId(inboundEnvelope.getMessageId());
- run.setSourceUserVersion(version);
+ run.setSourceUserVersion(inboundEnvelope.getHistoryAnchorVersion());
run.setReplyTarget(inboundEnvelope.getReplyTarget());
run.setStatus(RunStatus.QUEUED);
run.setCreatedAt(System.currentTimeMillis());
@@ -305,6 +356,7 @@ public class AgentRuntimeService {
ConversationExecutionRequest request = new ConversationExecutionRequest();
request.setSessionKey(inboundEnvelope.getSessionKey());
request.setCurrentMessage(inboundEnvelope.getContent());
+ request.setCurrentMessageTriggerType(inboundEnvelope.getTriggerType());
request.setHistory(runtimeStoreService.loadConversationHistoryBefore(inboundEnvelope.getSessionKey(), inboundEnvelope.getSessionVersion()));
request.setSpawnTaskSupport((taskDescription, batchKey) -> spawnTask(runId, inboundEnvelope, taskDescription, batchKey));
request.setRunQuerySupport(buildRunQuerySupport(inboundEnvelope.getSessionKey()));
@@ -314,6 +366,7 @@ public class AgentRuntimeService {
String response = conversationAgent.execute(request, progress -> {
latestProgress[0] = progress;
runtimeStoreService.appendRunEvent(runId, "progress", progress);
+ dispatchProgressOutbound(runId, inboundEnvelope, progress);
});
AgentRun latestRun = runtimeStoreService.getRun(runId);
if (latestRun != null) {
@@ -346,7 +399,7 @@ public class AgentRuntimeService {
inboundEnvelope.getSessionKey(),
runId,
inboundEnvelope.getMessageId(),
- inboundEnvelope.getSessionVersion(),
+ resolveAssistantSourceUserVersion(inboundEnvelope),
visibleResponse
);
}
@@ -435,15 +488,17 @@ public class AgentRuntimeService {
childInbound.setReceivedAt(now);
childInbound.setSessionKey(childSessionKey);
childInbound.setReplyTarget(null);
+ childInbound.setTriggerType(InboundTriggerType.SYSTEM_VISIBLE);
long version = runtimeStoreService.appendInboundConversationEvent(childInbound);
childInbound.setSessionVersion(version);
+ childInbound.setHistoryAnchorVersion(resolveHistoryAnchorVersion(childInbound, version));
AgentRun childRun = new AgentRun();
childRun.setRunId(runtimeStoreService.newRunId());
childRun.setSessionKey(childSessionKey);
childRun.setSourceMessageId(childMessageId);
- childRun.setSourceUserVersion(version);
+ childRun.setSourceUserVersion(childInbound.getHistoryAnchorVersion());
childRun.setStatus(RunStatus.QUEUED);
childRun.setCreatedAt(now);
childRun.setParentRunId(parentRunId);
@@ -510,6 +565,36 @@ public class AgentRuntimeService {
);
}
+ /**
+ * 计算当前入站消息对应的历史锚点版本。
+ *
+ * @param inboundEnvelope 入站消息
+ * @param version 当前入站事件版本
+ * @return 历史锚点版本
+ */
+ private long resolveHistoryAnchorVersion(InboundEnvelope inboundEnvelope, long version) {
+ if (inboundEnvelope.getHistoryAnchorVersion() > 0) {
+ return inboundEnvelope.getHistoryAnchorVersion();
+ }
+ if (inboundEnvelope.getTriggerType() == null || inboundEnvelope.getTriggerType() == InboundTriggerType.USER) {
+ return version;
+ }
+ return runtimeStoreService.getLatestUserConversationVersion(inboundEnvelope.getSessionKey());
+ }
+
+ /**
+ * 计算助手回复事件要挂载到的来源用户版本。
+ *
+ * @param inboundEnvelope 入站消息
+ * @return 来源用户版本
+ */
+ private long resolveAssistantSourceUserVersion(InboundEnvelope inboundEnvelope) {
+ if (inboundEnvelope.getHistoryAnchorVersion() > 0) {
+ return inboundEnvelope.getHistoryAnchorVersion();
+ }
+ return inboundEnvelope.getSessionVersion();
+ }
+
/**
* 构造子运行完成后回流父会话的内部消息。
*
@@ -624,6 +709,35 @@ public class AgentRuntimeService {
};
}
+ /**
+ * 若当前渠道支持进度更新,则将运行中的增量内容透传到外部渠道。
+ *
+ * @param runId 运行任务标识
+ * @param inboundEnvelope 当前入站消息
+ * @param progress 增量内容
+ */
+ private void dispatchProgressOutbound(String runId, InboundEnvelope inboundEnvelope, String progress) {
+ if (StrUtil.isBlank(progress)
+ || inboundEnvelope == null
+ || !inboundEnvelope.isExternalReplyEnabled()
+ || inboundEnvelope.getReplyTarget() == null
+ || inboundEnvelope.getReplyTarget().isDebugWeb()) {
+ return;
+ }
+
+ ChannelAdapter adapter = channelRegistry.get(inboundEnvelope.getReplyTarget().getChannelType());
+ if (adapter == null || !adapter.supportsProgressUpdates()) {
+ return;
+ }
+
+ OutboundEnvelope outboundEnvelope = new OutboundEnvelope();
+ outboundEnvelope.setRunId(runId);
+ outboundEnvelope.setReplyTarget(inboundEnvelope.getReplyTarget());
+ outboundEnvelope.setContent(progress);
+ outboundEnvelope.setProgress(true);
+ channelRegistry.send(outboundEnvelope);
+ }
+
/**
* 判断当前回复是否表示“不要对外回复”。
*
diff --git a/src/main/java/com/jimuqu/claw/agent/runtime/ConversationExecutionRequest.java b/src/main/java/com/jimuqu/claw/agent/runtime/ConversationExecutionRequest.java
index 16c0ca3ae5cd4aa0e4ba81a379aa07bac1cca712..df90b329a34ea8d73aa45515f3d1c155b964bf30 100644
--- a/src/main/java/com/jimuqu/claw/agent/runtime/ConversationExecutionRequest.java
+++ b/src/main/java/com/jimuqu/claw/agent/runtime/ConversationExecutionRequest.java
@@ -1,5 +1,6 @@
package com.jimuqu.claw.agent.runtime;
+import com.jimuqu.claw.agent.model.InboundTriggerType;
import org.noear.solon.ai.chat.message.ChatMessage;
import java.util.ArrayList;
@@ -13,6 +14,8 @@ public class ConversationExecutionRequest {
private String sessionKey;
/** 当前待处理的用户消息。 */
private String currentMessage;
+ /** 当前消息的触发类型。 */
+ private InboundTriggerType currentMessageTriggerType = InboundTriggerType.USER;
/** 历史消息列表。 */
private List history = new ArrayList<>();
/** 当前运行可用的子任务派生能力。 */
@@ -58,6 +61,24 @@ public class ConversationExecutionRequest {
this.currentMessage = currentMessage;
}
+ /**
+ * 返回当前消息的触发类型。
+ *
+ * @return 触发类型
+ */
+ public InboundTriggerType getCurrentMessageTriggerType() {
+ return currentMessageTriggerType;
+ }
+
+ /**
+ * 设置当前消息的触发类型。
+ *
+ * @param currentMessageTriggerType 触发类型
+ */
+ public void setCurrentMessageTriggerType(InboundTriggerType currentMessageTriggerType) {
+ this.currentMessageTriggerType = currentMessageTriggerType;
+ }
+
/**
* 返回历史消息列表。
*
diff --git a/src/main/java/com/jimuqu/claw/agent/runtime/SolonAiConversationAgent.java b/src/main/java/com/jimuqu/claw/agent/runtime/SolonAiConversationAgent.java
index 8046f627b3b714ffb522b533db79e1e7cdbf20c3..87ca12273515ae5e140694f0c19d10f008ada255 100644
--- a/src/main/java/com/jimuqu/claw/agent/runtime/SolonAiConversationAgent.java
+++ b/src/main/java/com/jimuqu/claw/agent/runtime/SolonAiConversationAgent.java
@@ -1,5 +1,6 @@
package com.jimuqu.claw.agent.runtime;
+import com.jimuqu.claw.agent.model.InboundTriggerType;
import com.jimuqu.claw.agent.tool.ConversationRuntimeTools;
import com.jimuqu.claw.agent.tool.JobTools;
import com.jimuqu.claw.agent.tool.WorkspaceAgentTools;
@@ -69,17 +70,24 @@ public class SolonAiConversationAgent implements ConversationAgent {
}
AtomicReference latestChunk = new AtomicReference<>("");
+ VisibleProgressAccumulator progressAccumulator = new VisibleProgressAccumulator();
+ String prompt = resolvePrompt(request, session);
Flux stream = buildAgent(request)
- .prompt(request.getCurrentMessage())
+ .prompt(prompt)
.session(session)
.stream();
AgentChunk finalChunk = stream.doOnNext(chunk -> {
- String content = chunk.getContent();
- if (StrUtil.isNotBlank(content) && !content.equals(latestChunk.get())) {
- latestChunk.set(content);
- progressConsumer.accept(content);
+ ChatMessage message = chunk.getMessage();
+ String content = message.getContent();
+ boolean thinking = message.isThinking();
+ boolean toolCalls = message.isToolCalls();
+
+ String visibleProgress = progressAccumulator.append(content, thinking, toolCalls);
+ if (StrUtil.isNotBlank(visibleProgress) && !visibleProgress.equals(latestChunk.get())) {
+ latestChunk.set(visibleProgress);
+ progressConsumer.accept(visibleProgress);
}
}).blockLast();
@@ -113,4 +121,30 @@ public class SolonAiConversationAgent implements ConversationAgent {
.sessionWindowSize(64)
.build();
}
+
+ /**
+ * 为不同类型的触发生成合适的当前轮次提示。
+ *
+ * @param request 当前执行请求
+ * @param session 会话上下文
+ * @return 当前轮次提示
+ */
+ private String resolvePrompt(ConversationExecutionRequest request, SystemAwareAgentSession session) {
+ InboundTriggerType triggerType = request == null ? InboundTriggerType.USER : request.getCurrentMessageTriggerType();
+ String currentMessage = request == null ? null : request.getCurrentMessage();
+ if (triggerType == null || triggerType == InboundTriggerType.USER) {
+ return currentMessage;
+ }
+
+ if (currentMessage != null && currentMessage.trim().length() > 0) {
+ session.addMessage(ChatMessage.ofSystem(currentMessage));
+ }
+
+ if (triggerType == InboundTriggerType.SYSTEM_SILENT) {
+ return "这是一次静默内部检查。请结合最新的 system 消息和既有上下文继续处理,不要把它当作用户新消息。"
+ + "如果当前没有需要对外说明的事项,请返回简洁状态或 NO_REPLY。";
+ }
+
+ return "这是一次内部系统触发。请优先依据最新的 system 消息和既有上下文继续处理,不要把它当作用户新消息。";
+ }
}
diff --git a/src/main/java/com/jimuqu/claw/agent/runtime/VisibleProgressAccumulator.java b/src/main/java/com/jimuqu/claw/agent/runtime/VisibleProgressAccumulator.java
new file mode 100644
index 0000000000000000000000000000000000000000..d7e87a74c10a2a3b2a0541f2b12709228d30a59e
--- /dev/null
+++ b/src/main/java/com/jimuqu/claw/agent/runtime/VisibleProgressAccumulator.java
@@ -0,0 +1,87 @@
+package com.jimuqu.claw.agent.runtime;
+
+import cn.hutool.core.util.StrUtil;
+
+/**
+ * 将模型流式输出折叠为“对用户可见的累计正文”。
+ */
+class VisibleProgressAccumulator {
+ /** 最近一次对用户可见的累计正文。 */
+ private String visibleText = "";
+
+ /**
+ * 追加一段新的流式内容,并返回最新的累计正文。
+ *
+ * @param chunkContent 新片段
+ * @param thinking 是否为思考内容
+ * @param toolCalls 是否为工具调用内容
+ * @return 若当前没有可见正文则返回 null;否则返回累计正文
+ */
+ public String append(String chunkContent, boolean thinking, boolean toolCalls) {
+ if (thinking || toolCalls || StrUtil.isBlank(chunkContent)) {
+ return null;
+ }
+
+ String normalizedChunk = normalizeChunk(chunkContent);
+ String candidate = mergeVisibleText(visibleText, normalizedChunk);
+ if (StrUtil.isBlank(candidate) || StrUtil.equals(candidate, visibleText)) {
+ return null;
+ }
+
+ visibleText = candidate;
+ return visibleText;
+ }
+
+ /**
+ * 返回当前累计正文。
+ *
+ * @return 累计正文
+ */
+ public String getVisibleText() {
+ return visibleText;
+ }
+
+ /**
+ * 预处理单个片段。
+ *
+ * @param chunkContent 原始片段
+ * @return 归一化后的片段
+ */
+ private String normalizeChunk(String chunkContent) {
+ return StrUtil.blankToDefault(chunkContent, "").replace("\r\n", "\n");
+ }
+
+ /**
+ * 将新的可见片段合并为累计正文。
+ *
+ * @param current 当前累计正文
+ * @param next 新片段
+ * @return 合并后的累计正文
+ */
+ private String mergeVisibleText(String current, String next) {
+ String currentValue = StrUtil.blankToDefault(current, "");
+ String nextValue = StrUtil.blankToDefault(next, "");
+
+ if (StrUtil.isBlank(currentValue)) {
+ return StrUtil.trim(nextValue);
+ }
+
+ if (StrUtil.equals(currentValue, StrUtil.trim(nextValue))) {
+ return currentValue;
+ }
+
+ if (StrUtil.startWith(nextValue, currentValue)) {
+ return StrUtil.trim(nextValue);
+ }
+
+ if (StrUtil.startWith(StrUtil.trim(nextValue), currentValue)) {
+ return StrUtil.trim(nextValue);
+ }
+
+ if (StrUtil.startWith(currentValue, StrUtil.trim(nextValue))) {
+ return currentValue;
+ }
+
+ return StrUtil.trim(currentValue + nextValue);
+ }
+}
diff --git a/src/main/java/com/jimuqu/claw/agent/store/RuntimeStoreService.java b/src/main/java/com/jimuqu/claw/agent/store/RuntimeStoreService.java
index a8c2876be7aad021632e70134f207ccfa0d26308..a587191a296f515a4c8016d0f1a8e361406f75d3 100644
--- a/src/main/java/com/jimuqu/claw/agent/store/RuntimeStoreService.java
+++ b/src/main/java/com/jimuqu/claw/agent/store/RuntimeStoreService.java
@@ -10,6 +10,7 @@ import com.jimuqu.claw.agent.model.ChildRunSpawnedData;
import com.jimuqu.claw.agent.model.ChannelType;
import com.jimuqu.claw.agent.model.ConversationEvent;
import com.jimuqu.claw.agent.model.InboundEnvelope;
+import com.jimuqu.claw.agent.model.InboundTriggerType;
import com.jimuqu.claw.agent.model.LatestReplyRoute;
import com.jimuqu.claw.agent.model.ReplyTarget;
import com.jimuqu.claw.agent.model.RunEvent;
@@ -114,9 +115,10 @@ public class RuntimeStoreService {
public long appendInboundConversationEvent(InboundEnvelope inboundEnvelope) {
ConversationEvent event = new ConversationEvent();
event.setSessionKey(inboundEnvelope.getSessionKey());
- event.setEventType(inboundEnvelope.isPersistInboundConversationEvent() ? "user_message" : "system_event");
+ event.setEventType(resolveInboundEventType(inboundEnvelope));
event.setSourceMessageId(inboundEnvelope.getMessageId());
- event.setRole(inboundEnvelope.isPersistInboundConversationEvent() ? "user" : "system");
+ event.setSourceUserVersion(resolveInboundSourceUserVersion(inboundEnvelope));
+ event.setRole(resolveInboundEventRole(inboundEnvelope));
event.setContent(inboundEnvelope.getContent());
event.setCreatedAt(inboundEnvelope.getReceivedAt());
return appendConversationEvent(inboundEnvelope.getSessionKey(), event);
@@ -257,23 +259,26 @@ public class RuntimeStoreService {
public List loadConversationHistoryBefore(String sessionKey, long beforeUserVersion) {
List allEvents = readConversationEvents(sessionKey);
List userEvents = new ArrayList<>();
- Map> repliesBySource = new LinkedHashMap<>();
- Map> sideEventsByAnchor = new LinkedHashMap<>();
- List unanchoredSideEvents = new ArrayList<>();
+ Map> anchoredEventsBySource = new LinkedHashMap<>();
+ List unanchoredEvents = new ArrayList<>();
for (ConversationEvent event : allEvents) {
if ("user_message".equals(event.getEventType()) && event.getVersion() < beforeUserVersion) {
userEvents.add(event);
}
if ("assistant_reply".equals(event.getEventType()) && event.getSourceUserVersion() < beforeUserVersion) {
- repliesBySource.computeIfAbsent(event.getSourceUserVersion(), key -> new ArrayList<>()).add(event);
+ if (event.getSourceUserVersion() > 0) {
+ anchoredEventsBySource.computeIfAbsent(event.getSourceUserVersion(), key -> new ArrayList<>()).add(event);
+ } else {
+ unanchoredEvents.add(event);
+ }
}
if (event.getVersion() < beforeUserVersion && isRenderableSystemEvent(event)) {
long anchorVersion = event.getSourceUserVersion();
if (anchorVersion > 0) {
- sideEventsByAnchor.computeIfAbsent(anchorVersion, key -> new ArrayList<>()).add(event);
+ anchoredEventsBySource.computeIfAbsent(anchorVersion, key -> new ArrayList<>()).add(event);
} else {
- unanchoredSideEvents.add(event);
+ unanchoredEvents.add(event);
}
}
}
@@ -282,25 +287,18 @@ public class RuntimeStoreService {
List history = new ArrayList<>();
for (ConversationEvent userEvent : userEvents) {
history.add(ChatMessage.ofUser(userEvent.getContent()));
- List replies = repliesBySource.get(userEvent.getVersion());
- if (replies != null) {
- replies.sort(Comparator.comparingLong(ConversationEvent::getVersion));
- for (ConversationEvent reply : replies) {
- history.add(ChatMessage.ofAssistant(reply.getContent()));
- }
- }
- List sideEvents = sideEventsByAnchor.get(userEvent.getVersion());
- if (sideEvents != null) {
- sideEvents.sort(Comparator.comparingLong(ConversationEvent::getVersion));
- for (ConversationEvent sideEvent : sideEvents) {
- history.add(ChatMessage.ofSystem(renderConversationEvent(sideEvent)));
+ List anchoredEvents = anchoredEventsBySource.get(userEvent.getVersion());
+ if (anchoredEvents != null) {
+ anchoredEvents.sort(Comparator.comparingLong(ConversationEvent::getVersion));
+ for (ConversationEvent anchoredEvent : anchoredEvents) {
+ history.add(toHistoryMessage(anchoredEvent));
}
}
}
- unanchoredSideEvents.sort(Comparator.comparingLong(ConversationEvent::getVersion));
- for (ConversationEvent sideEvent : unanchoredSideEvents) {
- history.add(ChatMessage.ofSystem(renderConversationEvent(sideEvent)));
+ unanchoredEvents.sort(Comparator.comparingLong(ConversationEvent::getVersion));
+ for (ConversationEvent event : unanchoredEvents) {
+ history.add(toHistoryMessage(event));
}
return history;
@@ -655,6 +653,23 @@ public class RuntimeStoreService {
}
}
+ /**
+ * 返回某个会话最近一次真实用户消息的版本号。
+ *
+ * @param sessionKey 会话键
+ * @return 最新用户消息版本号;不存在则返回 0
+ */
+ public long getLatestUserConversationVersion(String sessionKey) {
+ List events = readConversationEvents(sessionKey);
+ for (int i = events.size() - 1; i >= 0; i--) {
+ ConversationEvent event = events.get(i);
+ if ("user_message".equals(event.getEventType())) {
+ return event.getVersion();
+ }
+ }
+ return 0L;
+ }
+
/**
* 记录最近一次外部回复目标。
*
@@ -852,6 +867,61 @@ public class RuntimeStoreService {
|| "child_run_completed".equals(event.getEventType());
}
+ /**
+ * 将会话事件转换为历史消息。
+ *
+ * @param event 会话事件
+ * @return 历史消息
+ */
+ private ChatMessage toHistoryMessage(ConversationEvent event) {
+ if ("assistant_reply".equals(event.getEventType())) {
+ return ChatMessage.ofAssistant(event.getContent());
+ }
+ return ChatMessage.ofSystem(renderConversationEvent(event));
+ }
+
+ /**
+ * 根据入站触发类型返回对应的会话事件类型。
+ *
+ * @param inboundEnvelope 入站消息
+ * @return 会话事件类型
+ */
+ private String resolveInboundEventType(InboundEnvelope inboundEnvelope) {
+ InboundTriggerType triggerType = inboundEnvelope == null ? null : inboundEnvelope.getTriggerType();
+ if (triggerType == null || triggerType == InboundTriggerType.USER) {
+ return "user_message";
+ }
+ return "system_event";
+ }
+
+ /**
+ * 根据入站触发类型返回对应的会话事件角色。
+ *
+ * @param inboundEnvelope 入站消息
+ * @return 会话事件角色
+ */
+ private String resolveInboundEventRole(InboundEnvelope inboundEnvelope) {
+ InboundTriggerType triggerType = inboundEnvelope == null ? null : inboundEnvelope.getTriggerType();
+ if (triggerType == null || triggerType == InboundTriggerType.USER) {
+ return "user";
+ }
+ return "system";
+ }
+
+ /**
+ * 解析入站事件要写入的历史锚点版本。
+ *
+ * @param inboundEnvelope 入站消息
+ * @return 历史锚点版本
+ */
+ private long resolveInboundSourceUserVersion(InboundEnvelope inboundEnvelope) {
+ InboundTriggerType triggerType = inboundEnvelope == null ? null : inboundEnvelope.getTriggerType();
+ if (triggerType == null || triggerType == InboundTriggerType.USER) {
+ return 0L;
+ }
+ return inboundEnvelope.getHistoryAnchorVersion();
+ }
+
private String renderConversationEvent(ConversationEvent event) {
if ("child_run_spawned".equals(event.getEventType())) {
ChildRunSpawnedData data = JSONUtil.toBean(event.getEventDataJson(), ChildRunSpawnedData.class);
diff --git a/src/main/java/com/jimuqu/claw/agent/tool/ConversationRuntimeTools.java b/src/main/java/com/jimuqu/claw/agent/tool/ConversationRuntimeTools.java
index 34cb0718fb18a64e93e62846cedb27f701f8f80a..f8f1a96006c9fd59c1531224de87e3e8d67b1ad7 100644
--- a/src/main/java/com/jimuqu/claw/agent/tool/ConversationRuntimeTools.java
+++ b/src/main/java/com/jimuqu/claw/agent/tool/ConversationRuntimeTools.java
@@ -67,11 +67,6 @@ public class ConversationRuntimeTools {
return workspaceAgentTools.editFile(filePath, oldText, newText);
}
- @ToolMapping(name = "exec_command", description = "在工作区目录执行命令,返回标准输出与标准错误")
- public String execCommand(@Param(description = "要执行的命令文本") String command) throws Exception {
- return workspaceAgentTools.execCommand(command);
- }
-
@ToolMapping(name = "notify_user", description = "向当前会话已绑定的用户主动发送通知;只发送,不接收")
public String notifyUser(
@Param(description = "通知内容") String message,
diff --git a/src/main/java/com/jimuqu/claw/agent/tool/WorkspaceAgentTools.java b/src/main/java/com/jimuqu/claw/agent/tool/WorkspaceAgentTools.java
index b2c426717935fcd68967fb3fe11bdcf899098f54..bc5472c4b5b983547f318074a320acda3b13bb79 100644
--- a/src/main/java/com/jimuqu/claw/agent/tool/WorkspaceAgentTools.java
+++ b/src/main/java/com/jimuqu/claw/agent/tool/WorkspaceAgentTools.java
@@ -1,22 +1,17 @@
package com.jimuqu.claw.agent.tool;
import cn.hutool.core.io.FileUtil;
-import cn.hutool.core.io.IoUtil;
-import cn.hutool.core.util.StrUtil;
import com.jimuqu.claw.agent.workspace.AgentWorkspaceService;
import org.noear.solon.ai.annotation.ToolMapping;
import org.noear.solon.annotation.Param;
import java.io.IOException;
-import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
-import java.util.concurrent.CompletableFuture;
-import java.util.concurrent.TimeUnit;
/**
- * 提供受工作区边界保护的基础文件与命令工具。
+ * 提供受工作区边界保护的基础文件工具。
*/
public class WorkspaceAgentTools {
private static final int MAX_RESULT_CHARS = 8000;
@@ -77,49 +72,8 @@ public class WorkspaceAgentTools {
return "已修改文件: " + target;
}
- @ToolMapping(name = "exec_command", description = "在工作区目录执行命令,返回标准输出与标准错误")
- public String execCommand(@Param(description = "要执行的命令文本") String command) throws Exception {
- if (StrUtil.isBlank(command)) {
- return "执行失败: command 不能为空";
- }
-
- ProcessBuilder builder = new ProcessBuilder(buildShellCommand(command));
- builder.directory(workspaceService.getWorkspaceDir());
- builder.redirectErrorStream(true);
-
- Process process = builder.start();
- CompletableFuture outputFuture = CompletableFuture.supplyAsync(() -> readProcessOutput(process));
-
- boolean completed = process.waitFor(30, TimeUnit.SECONDS);
- if (!completed) {
- process.destroyForcibly();
- return "执行超时(30s): " + command;
- }
-
- String output = outputFuture.get(5, TimeUnit.SECONDS);
- String body = StrUtil.isBlank(output) ? "(无输出)" : output.trim();
- return truncate("exitCode=" + process.exitValue() + "\n" + body);
- }
-
- private String[] buildShellCommand(String command) {
- String os = System.getProperty("os.name", "").toLowerCase();
- if (os.contains("win")) {
- return new String[]{"powershell", "-NoProfile", "-Command", command};
- }
-
- return new String[]{"/bin/sh", "-lc", command};
- }
-
- private String readProcessOutput(Process process) {
- try (InputStream inputStream = process.getInputStream()) {
- return IoUtil.read(inputStream, "UTF-8");
- } catch (IOException e) {
- return "读取命令输出失败: " + e.getMessage();
- }
- }
-
private Path resolvePath(String pathText, boolean allowMissingLeaf) throws IOException {
- if (StrUtil.isBlank(pathText)) {
+ if (pathText == null || pathText.trim().isEmpty()) {
throw new IllegalArgumentException("filePath 不能为空");
}
diff --git a/src/main/java/com/jimuqu/claw/agent/workspace/WorkspacePromptService.java b/src/main/java/com/jimuqu/claw/agent/workspace/WorkspacePromptService.java
index d1f2e88f91aa24bb9be2587d38522ecbc10d21b3..d3f0bd5e66b8df903fe17534f7dbe6c4096f25f3 100644
--- a/src/main/java/com/jimuqu/claw/agent/workspace/WorkspacePromptService.java
+++ b/src/main/java/com/jimuqu/claw/agent/workspace/WorkspacePromptService.java
@@ -42,7 +42,8 @@ public class WorkspacePromptService {
"",
"## 工作区",
"- 工作区是默认文件根目录;除非用户明确要求,不要把运行期文件写到别处。",
- "- 用户可编辑的工作区文件会在后文注入;如果存在 AGENTS.md、SOUL.md、USER.md、TOOLS.md、HEARTBEAT.md 等内容,应把它们视为当前运行的重要上下文。",
+ "- 用户可编辑的工作区文件会在后文注入;如果存在 AGENTS.md、SOUL.md、USER.md、TOOLS.md 等内容,应把它们视为当前运行的重要上下文。",
+ "- HEARTBEAT.md 只用于内部心跳或系统任务检查,不应在普通对话里直接当作用户消息理解。",
"",
"## 心跳",
"- 如果收到心跳检查且当前没有需要处理的事项,就简洁确认状态正常。",
@@ -113,7 +114,6 @@ public class WorkspacePromptService {
appendSection(lines, "身份记录", IDENTITY_FILE);
appendSection(lines, "用户画像", USER_FILE);
appendSection(lines, "工具备注", TOOLS_FILE);
- appendSection(lines, "心跳清单", HEARTBEAT_FILE);
appendSection(lines, "首次对话引导", BOOTSTRAP_FILE);
appendSection(lines, "长期记忆", MEMORY_FILE);
appendRecentDailyMemory(lines);
diff --git a/src/main/java/com/jimuqu/claw/channel/feishu/FeishuBotSender.java b/src/main/java/com/jimuqu/claw/channel/feishu/FeishuBotSender.java
new file mode 100644
index 0000000000000000000000000000000000000000..fc5487860dd8b0d94139ec4d8e5e05d5af11ffe9
--- /dev/null
+++ b/src/main/java/com/jimuqu/claw/channel/feishu/FeishuBotSender.java
@@ -0,0 +1,177 @@
+package com.jimuqu.claw.channel.feishu;
+
+import cn.hutool.core.util.StrUtil;
+import com.alibaba.fastjson.JSONArray;
+import com.alibaba.fastjson.JSONObject;
+import com.jimuqu.claw.agent.model.OutboundEnvelope;
+import com.jimuqu.claw.agent.model.ReplyTarget;
+import com.jimuqu.claw.config.SolonClawProperties;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * 负责向飞书发送 markdown 卡片,并在支持时对同一运行任务做卡片 patch 更新。
+ */
+public class FeishuBotSender {
+ /** 日志记录器。 */
+ private static final Logger log = LoggerFactory.getLogger(FeishuBotSender.class);
+ /** 飞书配置。 */
+ private final SolonClawProperties.Feishu properties;
+ /** 飞书消息网关。 */
+ private final FeishuMessageGateway messageGateway;
+ /** 运行任务对应的飞书消息 ID,用于流式 patch 更新。 */
+ private final Map progressMessageIds = new ConcurrentHashMap<>();
+
+ /**
+ * 创建飞书消息发送器。
+ *
+ * @param properties 飞书配置
+ */
+ public FeishuBotSender(SolonClawProperties.Feishu properties) {
+ this(properties, new FeishuSdkMessageGateway(properties));
+ }
+
+ /**
+ * 使用显式网关创建发送器。
+ *
+ * @param properties 飞书配置
+ * @param messageGateway 消息网关
+ */
+ FeishuBotSender(SolonClawProperties.Feishu properties, FeishuMessageGateway messageGateway) {
+ this.properties = properties;
+ this.messageGateway = messageGateway;
+ }
+
+ /**
+ * 发送或更新一条飞书消息。
+ *
+ * @param outboundEnvelope 出站消息
+ */
+ public void send(OutboundEnvelope outboundEnvelope) {
+ if (outboundEnvelope == null || outboundEnvelope.getReplyTarget() == null) {
+ return;
+ }
+
+ String content = normalizeContent(outboundEnvelope);
+ if (StrUtil.isBlank(content)) {
+ return;
+ }
+
+ ReplyTarget replyTarget = outboundEnvelope.getReplyTarget();
+ if (StrUtil.isBlank(replyTarget.getConversationId())) {
+ log.warn("Skip Feishu send because conversationId is missing.");
+ return;
+ }
+
+ try {
+ String runId = StrUtil.blankToDefault(outboundEnvelope.getRunId(), "runless:" + System.nanoTime());
+ String cardContent = cardMessageParam(content);
+ if (outboundEnvelope.isProgress() && properties.isStreamingReply()) {
+ sendProgress(runId, replyTarget, cardContent);
+ return;
+ }
+ sendFinal(runId, replyTarget, cardContent);
+ } catch (Exception exception) {
+ log.warn("Failed to send Feishu message: {}", exception.getMessage(), exception);
+ }
+ }
+
+ /**
+ * 发送一条运行中的增量卡片;若同 run 已存在消息则更新。
+ *
+ * @param runId 运行任务标识
+ * @param replyTarget 回复目标
+ * @param cardContent 卡片 JSON
+ * @throws Exception 发送异常
+ */
+ private void sendProgress(String runId, ReplyTarget replyTarget, String cardContent) throws Exception {
+ String messageId = progressMessageIds.get(runId);
+ if (StrUtil.isNotBlank(messageId)) {
+ messageGateway.patchCardMessage(messageId, cardContent);
+ return;
+ }
+
+ String createdMessageId = messageGateway.createCardMessage(replyTarget.getConversationId(), cardContent);
+ if (StrUtil.isNotBlank(createdMessageId)) {
+ progressMessageIds.put(runId, createdMessageId);
+ }
+ }
+
+ /**
+ * 发送最终结果;若此前已有进度卡片则直接 patch 为最终内容。
+ *
+ * @param runId 运行任务标识
+ * @param replyTarget 回复目标
+ * @param cardContent 卡片 JSON
+ * @throws Exception 发送异常
+ */
+ private void sendFinal(String runId, ReplyTarget replyTarget, String cardContent) throws Exception {
+ String messageId = progressMessageIds.get(runId);
+ if (StrUtil.isNotBlank(messageId)) {
+ messageGateway.patchCardMessage(messageId, cardContent);
+ progressMessageIds.remove(runId, messageId);
+ return;
+ }
+
+ messageGateway.createCardMessage(replyTarget.getConversationId(), cardContent);
+ }
+
+ /**
+ * 将消息内容包装成飞书交互式卡片 JSON。
+ *
+ * @param content markdown 文本
+ * @return 卡片 JSON 字符串
+ */
+ String cardMessageParam(String content) {
+ JSONObject root = new JSONObject();
+ root.put("schema", "2.0");
+
+ JSONObject config = new JSONObject();
+ config.put("update_multi", true);
+ config.put("streaming_mode", false);
+ root.put("config", config);
+
+ JSONObject body = new JSONObject();
+ body.put("direction", "vertical");
+ body.put("padding", "12px 12px 12px 12px");
+
+ JSONObject markdown = new JSONObject();
+ markdown.put("tag", "markdown");
+ markdown.put("content", StrUtil.blankToDefault(content, ""));
+ markdown.put("text_align", "left");
+ markdown.put("text_size", "normal");
+ markdown.put("margin", "0px 0px 0px 0px");
+
+ JSONArray elements = new JSONArray();
+ elements.add(markdown);
+ body.put("elements", elements);
+ root.put("body", body);
+ return root.toJSONString();
+ }
+
+ /**
+ * 将附件退化信息拼接到正文中。
+ *
+ * @param outboundEnvelope 出站消息
+ * @return 归一化后的文本
+ */
+ private String normalizeContent(OutboundEnvelope outboundEnvelope) {
+ String content = StrUtil.blankToDefault(outboundEnvelope.getContent(), "");
+ if (outboundEnvelope.getMedia() == null || outboundEnvelope.getMedia().isEmpty()) {
+ return content;
+ }
+
+ StringBuilder builder = new StringBuilder(content);
+ if (builder.length() > 0) {
+ builder.append("\n\n");
+ }
+ builder.append("附件暂以文本回退发送:\n");
+ for (String media : outboundEnvelope.getMedia()) {
+ builder.append("- ").append(media).append('\n');
+ }
+ return builder.toString().trim();
+ }
+}
diff --git a/src/main/java/com/jimuqu/claw/channel/feishu/FeishuChannelAdapter.java b/src/main/java/com/jimuqu/claw/channel/feishu/FeishuChannelAdapter.java
new file mode 100644
index 0000000000000000000000000000000000000000..dbcce1a79bfcacf13db571d7e456ddb5f331f416
--- /dev/null
+++ b/src/main/java/com/jimuqu/claw/channel/feishu/FeishuChannelAdapter.java
@@ -0,0 +1,383 @@
+package com.jimuqu.claw.channel.feishu;
+
+import cn.hutool.core.util.StrUtil;
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONArray;
+import com.alibaba.fastjson.JSONObject;
+import com.lark.oapi.event.EventDispatcher;
+import com.lark.oapi.service.im.ImService;
+import com.lark.oapi.service.im.v1.model.EventMessage;
+import com.lark.oapi.service.im.v1.model.EventSender;
+import com.lark.oapi.service.im.v1.model.P2MessageReceiveV1;
+import com.lark.oapi.service.im.v1.model.P2MessageReceiveV1Data;
+import com.lark.oapi.service.im.v1.model.UserId;
+import com.jimuqu.claw.agent.channel.ChannelAdapter;
+import com.jimuqu.claw.agent.model.ChannelType;
+import com.jimuqu.claw.agent.model.ConversationType;
+import com.jimuqu.claw.agent.model.InboundEnvelope;
+import com.jimuqu.claw.agent.model.OutboundEnvelope;
+import com.jimuqu.claw.agent.model.ReplyTarget;
+import com.jimuqu.claw.agent.runtime.AgentRuntimeService;
+import com.jimuqu.claw.config.SolonClawProperties;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.lang.reflect.Method;
+
+/**
+ * 负责接入飞书长连接机器人消息,并将其映射到统一运行时。
+ */
+public class FeishuChannelAdapter implements ChannelAdapter {
+ /** 日志记录器。 */
+ private static final Logger log = LoggerFactory.getLogger(FeishuChannelAdapter.class);
+ /** Agent 运行时服务。 */
+ private final AgentRuntimeService agentRuntimeService;
+ /** 飞书消息发送服务。 */
+ private final FeishuBotSender feishuBotSender;
+ /** 飞书渠道配置。 */
+ private final SolonClawProperties.Feishu properties;
+ /** 飞书长连接客户端。 */
+ private com.lark.oapi.ws.Client wsClient;
+
+ /**
+ * 创建飞书渠道适配器。
+ *
+ * @param agentRuntimeService Agent 运行时服务
+ * @param feishuBotSender 飞书消息发送服务
+ * @param properties 飞书配置
+ */
+ public FeishuChannelAdapter(
+ AgentRuntimeService agentRuntimeService,
+ FeishuBotSender feishuBotSender,
+ SolonClawProperties.Feishu properties
+ ) {
+ this.agentRuntimeService = agentRuntimeService;
+ this.feishuBotSender = feishuBotSender;
+ this.properties = properties;
+ }
+
+ /**
+ * 启动飞书长连接客户端。
+ */
+ public void start() {
+ if (!properties.isEnabled()) {
+ log.info("Feishu channel disabled.");
+ return;
+ }
+
+ if (!isConfigured()) {
+ log.warn("Feishu channel is enabled, but appId/appSecret is incomplete.");
+ return;
+ }
+
+ if (wsClient != null) {
+ return;
+ }
+
+ EventDispatcher eventHandler = EventDispatcher.newBuilder("", "")
+ .onP2MessageReceiveV1(new ImService.P2MessageReceiveV1Handler() {
+ @Override
+ public void handle(P2MessageReceiveV1 event) {
+ consumeInboundEvent(event);
+ }
+ })
+ .build();
+
+ wsClient = new com.lark.oapi.ws.Client.Builder(properties.getAppId(), properties.getAppSecret())
+ .eventHandler(eventHandler)
+ .autoReconnect(Boolean.TRUE)
+ .domain(StrUtil.blankToDefault(properties.getBaseDomain(), "https://open.feishu.cn"))
+ .build();
+ wsClient.start();
+ log.info("Feishu ws bot client started.");
+ }
+
+ /**
+ * 停止飞书长连接客户端。
+ */
+ public void stop() {
+ if (wsClient == null) {
+ return;
+ }
+
+ try {
+ Method disconnect = wsClient.getClass().getDeclaredMethod("disconnect");
+ disconnect.setAccessible(true);
+ disconnect.invoke(wsClient);
+ log.info("Feishu ws bot client stopped.");
+ } catch (Exception exception) {
+ log.warn("Failed to stop Feishu ws client cleanly: {}", exception.getMessage(), exception);
+ } finally {
+ wsClient = null;
+ }
+ }
+
+ @Override
+ public ChannelType channelType() {
+ return ChannelType.FEISHU;
+ }
+
+ @Override
+ public boolean supportsProgressUpdates() {
+ return properties.isStreamingReply();
+ }
+
+ @Override
+ public void send(OutboundEnvelope outboundEnvelope) {
+ feishuBotSender.send(outboundEnvelope);
+ }
+
+ /**
+ * 处理飞书接收到的消息事件。
+ *
+ * @param event 飞书消息事件
+ */
+ private void consumeInboundEvent(P2MessageReceiveV1 event) {
+ try {
+ InboundEnvelope inboundEnvelope = toInboundEnvelope(event);
+ if (inboundEnvelope != null) {
+ agentRuntimeService.submitInbound(inboundEnvelope);
+ }
+ } catch (Throwable throwable) {
+ log.warn("Failed to consume Feishu bot message: {}", throwable.getMessage(), throwable);
+ }
+ }
+
+ /**
+ * 将飞书消息事件转换为统一入站模型。
+ *
+ * @param event 飞书消息事件
+ * @return 入站消息;若不应处理则返回 null
+ */
+ InboundEnvelope toInboundEnvelope(P2MessageReceiveV1 event) {
+ if (event == null || event.getEvent() == null || event.getEvent().getMessage() == null) {
+ return null;
+ }
+
+ P2MessageReceiveV1Data data = event.getEvent();
+ EventMessage message = data.getMessage();
+ EventSender sender = data.getSender();
+ if (sender != null && StrUtil.equalsIgnoreCase(sender.getSenderType(), "app")) {
+ return null;
+ }
+
+ String senderId = resolveSenderId(sender == null ? null : sender.getSenderId());
+ String conversationId = message.getChatId();
+ if (StrUtil.isBlank(senderId) || StrUtil.isBlank(conversationId)) {
+ return null;
+ }
+
+ ConversationType conversationType = resolveConversationType(message);
+ if (!isAllowed(conversationType, senderId, conversationId)) {
+ log.info(
+ "Ignore Feishu message because whitelist does not match. senderId={}, conversationId={}, conversationType={}",
+ senderId,
+ conversationId,
+ conversationType
+ );
+ return null;
+ }
+
+ String content = extractContent(message);
+ if (StrUtil.isBlank(content)) {
+ return null;
+ }
+
+ InboundEnvelope inboundEnvelope = new InboundEnvelope();
+ inboundEnvelope.setMessageId(StrUtil.blankToDefault(message.getMessageId(), "feishu-" + System.nanoTime()));
+ inboundEnvelope.setChannelType(ChannelType.FEISHU);
+ inboundEnvelope.setChannelInstanceId("feishu-default");
+ inboundEnvelope.setSenderId(senderId);
+ inboundEnvelope.setConversationId(conversationId);
+ inboundEnvelope.setConversationType(conversationType);
+ inboundEnvelope.setContent(content);
+ inboundEnvelope.setReceivedAt(parseTime(message.getCreateTime()));
+ inboundEnvelope.setSessionKey("feishu:" + conversationType.name().toLowerCase() + ":" + conversationId);
+ inboundEnvelope.setReplyTarget(new ReplyTarget(ChannelType.FEISHU, conversationType, conversationId, senderId));
+ return inboundEnvelope;
+ }
+
+ /**
+ * 提取飞书消息中的文本内容。
+ *
+ * @param message 飞书事件消息
+ * @return 文本内容
+ */
+ private String extractContent(EventMessage message) {
+ if (message == null || StrUtil.isBlank(message.getMessageType()) || StrUtil.isBlank(message.getContent())) {
+ return null;
+ }
+
+ String messageType = message.getMessageType().trim().toLowerCase();
+ if ("text".equals(messageType)) {
+ JSONObject jsonObject = parseObject(message.getContent());
+ return jsonObject == null ? null : StrUtil.trim(jsonObject.getString("text"));
+ }
+ if ("post".equals(messageType)) {
+ return extractPostText(parseObject(message.getContent())).trim();
+ }
+ if ("file".equals(messageType)) {
+ return "收到文件消息";
+ }
+ if ("image".equals(messageType)) {
+ return "收到图片消息";
+ }
+ return null;
+ }
+
+ /**
+ * 将 post 富文本中的 text 片段抽取为普通文本。
+ *
+ * @param jsonObject post 内容 JSON
+ * @return 抽取后的文本
+ */
+ private String extractPostText(JSONObject jsonObject) {
+ if (jsonObject == null || jsonObject.isEmpty()) {
+ return "";
+ }
+
+ StringBuilder builder = new StringBuilder();
+ appendPostText(jsonObject, builder);
+ return builder.toString().trim();
+ }
+
+ /**
+ * 递归收集富文本中的标题与文本节点。
+ *
+ * @param node 当前节点
+ * @param builder 文本累积器
+ */
+ private void appendPostText(Object node, StringBuilder builder) {
+ if (node instanceof JSONObject) {
+ JSONObject jsonObject = (JSONObject) node;
+ String title = StrUtil.trim(jsonObject.getString("title"));
+ if (StrUtil.isNotBlank(title)) {
+ if (builder.length() > 0) {
+ builder.append('\n');
+ }
+ builder.append(title);
+ }
+
+ String tag = StrUtil.blankToDefault(jsonObject.getString("tag"), "");
+ if ("text".equals(tag)) {
+ String text = StrUtil.trim(jsonObject.getString("text"));
+ if (StrUtil.isNotBlank(text)) {
+ if (builder.length() > 0) {
+ builder.append('\n');
+ }
+ builder.append(text);
+ }
+ }
+
+ for (String key : jsonObject.keySet()) {
+ appendPostText(jsonObject.get(key), builder);
+ }
+ return;
+ }
+
+ if (node instanceof JSONArray) {
+ JSONArray jsonArray = (JSONArray) node;
+ for (Object item : jsonArray) {
+ appendPostText(item, builder);
+ }
+ }
+ }
+
+ /**
+ * 推断飞书消息属于私聊还是群聊。
+ *
+ * @param message 飞书事件消息
+ * @return 会话类型
+ */
+ private ConversationType resolveConversationType(EventMessage message) {
+ return StrUtil.equalsIgnoreCase(message.getChatType(), "p2p")
+ ? ConversationType.PRIVATE
+ : ConversationType.GROUP;
+ }
+
+ /**
+ * 判断该消息是否命中允许列表。
+ *
+ * @param conversationType 会话类型
+ * @param senderId 发送者标识
+ * @param conversationId 会话标识
+ * @return 若允许处理则返回 true
+ */
+ private boolean isAllowed(ConversationType conversationType, String senderId, String conversationId) {
+ if (conversationType == ConversationType.GROUP) {
+ return properties.getGroupAllowFrom().isEmpty() || properties.getGroupAllowFrom().contains(conversationId);
+ }
+ return properties.getAllowFrom().isEmpty() || properties.getAllowFrom().contains(senderId);
+ }
+
+ /**
+ * 解析飞书事件时间戳。
+ *
+ * @param createTime 时间文本
+ * @return 时间戳
+ */
+ private long parseTime(String createTime) {
+ try {
+ return StrUtil.isBlank(createTime) ? System.currentTimeMillis() : Long.parseLong(createTime);
+ } catch (NumberFormatException exception) {
+ return System.currentTimeMillis();
+ }
+ }
+
+ /**
+ * 解析发送者标识。
+ *
+ * @param userId 用户标识对象
+ * @return 发送者标识
+ */
+ private String resolveSenderId(UserId userId) {
+ if (userId == null) {
+ return null;
+ }
+ String senderId = firstNonBlank(userId.getOpenId(), userId.getUserId());
+ return firstNonBlank(senderId, userId.getUnionId());
+ }
+
+ /**
+ * 判断飞书配置是否完整。
+ *
+ * @return 若完整则返回 true
+ */
+ private boolean isConfigured() {
+ return StrUtil.isNotBlank(properties.getAppId()) && StrUtil.isNotBlank(properties.getAppSecret());
+ }
+
+ /**
+ * 将 JSON 文本解析为对象。
+ *
+ * @param content JSON 文本
+ * @return JSON 对象
+ */
+ private JSONObject parseObject(String content) {
+ try {
+ Object node = JSON.parse(content);
+ return node instanceof JSONObject ? (JSONObject) node : null;
+ } catch (Exception exception) {
+ log.debug("Failed to parse Feishu message content: {}", content, exception);
+ return null;
+ }
+ }
+
+ /**
+ * 返回多个候选值中第一个非空白值。
+ *
+ * @param values 候选值列表
+ * @return 第一个非空白值
+ */
+ private String firstNonBlank(String... values) {
+ if (values == null) {
+ return null;
+ }
+ for (String value : values) {
+ if (StrUtil.isNotBlank(value)) {
+ return value;
+ }
+ }
+ return null;
+ }
+}
diff --git a/src/main/java/com/jimuqu/claw/channel/feishu/FeishuMessageGateway.java b/src/main/java/com/jimuqu/claw/channel/feishu/FeishuMessageGateway.java
new file mode 100644
index 0000000000000000000000000000000000000000..f969f3b7fbf4832b0d0c346eeb82125f741516fd
--- /dev/null
+++ b/src/main/java/com/jimuqu/claw/channel/feishu/FeishuMessageGateway.java
@@ -0,0 +1,25 @@
+package com.jimuqu.claw.channel.feishu;
+
+/**
+ * 抽象飞书消息创建与更新能力,便于在单元测试中替换底层 SDK。
+ */
+public interface FeishuMessageGateway {
+ /**
+ * 向指定 chat 发送一条交互式卡片消息。
+ *
+ * @param chatId chat 标识
+ * @param cardContent 卡片 JSON
+ * @return 新创建的消息 ID
+ * @throws Exception 发送异常
+ */
+ String createCardMessage(String chatId, String cardContent) throws Exception;
+
+ /**
+ * 更新一条已发送的卡片消息。
+ *
+ * @param messageId 消息 ID
+ * @param cardContent 卡片 JSON
+ * @throws Exception 更新异常
+ */
+ void patchCardMessage(String messageId, String cardContent) throws Exception;
+}
diff --git a/src/main/java/com/jimuqu/claw/channel/feishu/FeishuSdkMessageGateway.java b/src/main/java/com/jimuqu/claw/channel/feishu/FeishuSdkMessageGateway.java
new file mode 100644
index 0000000000000000000000000000000000000000..71dea869f620d485dfbfd1cd11a370bbd3f06616
--- /dev/null
+++ b/src/main/java/com/jimuqu/claw/channel/feishu/FeishuSdkMessageGateway.java
@@ -0,0 +1,77 @@
+package com.jimuqu.claw.channel.feishu;
+
+import cn.hutool.core.util.StrUtil;
+import com.lark.oapi.Client;
+import com.lark.oapi.service.im.v1.enums.MsgTypeEnum;
+import com.lark.oapi.service.im.v1.enums.ReceiveIdTypeEnum;
+import com.lark.oapi.service.im.v1.model.CreateMessageReq;
+import com.lark.oapi.service.im.v1.model.CreateMessageReqBody;
+import com.lark.oapi.service.im.v1.model.CreateMessageResp;
+import com.lark.oapi.service.im.v1.model.PatchMessageReq;
+import com.lark.oapi.service.im.v1.model.PatchMessageReqBody;
+import com.lark.oapi.service.im.v1.model.PatchMessageResp;
+import com.jimuqu.claw.config.SolonClawProperties;
+
+/**
+ * 基于飞书 Java SDK 的消息网关实现。
+ */
+public class FeishuSdkMessageGateway implements FeishuMessageGateway {
+ /** 飞书 OpenAPI 客户端。 */
+ private final Client client;
+
+ /**
+ * 创建基于 SDK 的消息网关。
+ *
+ * @param properties 飞书配置
+ */
+ public FeishuSdkMessageGateway(SolonClawProperties.Feishu properties) {
+ Client.Builder builder = Client.newBuilder(properties.getAppId(), properties.getAppSecret());
+ if (StrUtil.isNotBlank(properties.getBaseDomain())) {
+ builder.openBaseUrl(properties.getBaseDomain().trim());
+ }
+ this.client = builder.build();
+ }
+
+ /**
+ * 使用显式客户端创建消息网关。
+ *
+ * @param client 飞书客户端
+ */
+ FeishuSdkMessageGateway(Client client) {
+ this.client = client;
+ }
+
+ @Override
+ public String createCardMessage(String chatId, String cardContent) throws Exception {
+ CreateMessageReq req = CreateMessageReq.newBuilder()
+ .receiveIdType(ReceiveIdTypeEnum.CHAT_ID.getValue())
+ .createMessageReqBody(CreateMessageReqBody.newBuilder()
+ .receiveId(chatId)
+ .msgType(MsgTypeEnum.MSG_TYPE_INTERACTIVE.getValue())
+ .content(cardContent)
+ .build())
+ .build();
+
+ CreateMessageResp resp = client.im().message().create(req);
+ if (resp.getCode() != 0) {
+ throw new IllegalStateException("Feishu create message failed, code=" + resp.getCode() + ", msg=" + resp.getMsg());
+ }
+
+ return resp.getData() == null ? null : resp.getData().getMessageId();
+ }
+
+ @Override
+ public void patchCardMessage(String messageId, String cardContent) throws Exception {
+ PatchMessageReq req = PatchMessageReq.newBuilder()
+ .messageId(messageId)
+ .patchMessageReqBody(PatchMessageReqBody.newBuilder()
+ .content(cardContent)
+ .build())
+ .build();
+
+ PatchMessageResp resp = client.im().message().patch(req);
+ if (resp.getCode() != 0) {
+ throw new IllegalStateException("Feishu patch message failed, code=" + resp.getCode() + ", msg=" + resp.getMsg());
+ }
+ }
+}
diff --git a/src/main/java/com/jimuqu/claw/config/SolonClawConfig.java b/src/main/java/com/jimuqu/claw/config/SolonClawConfig.java
index e1b4558dc49086feb654a12c2095b5049a9e4abd..0af1111c832a31b972547d46ccefe1dc1e5dee4d 100644
--- a/src/main/java/com/jimuqu/claw/config/SolonClawConfig.java
+++ b/src/main/java/com/jimuqu/claw/config/SolonClawConfig.java
@@ -16,6 +16,8 @@ import com.jimuqu.claw.agent.workspace.WorkspacePromptService;
import com.jimuqu.claw.channel.dingtalk.DingTalkAccessTokenService;
import com.jimuqu.claw.channel.dingtalk.DingTalkChannelAdapter;
import com.jimuqu.claw.channel.dingtalk.DingTalkRobotSender;
+import com.jimuqu.claw.channel.feishu.FeishuBotSender;
+import com.jimuqu.claw.channel.feishu.FeishuChannelAdapter;
import org.noear.solon.ai.skills.cli.CliSkillProvider;
import org.noear.solon.ai.chat.ChatModel;
import org.noear.solon.annotation.Bean;
@@ -87,7 +89,9 @@ public class SolonClawConfig {
* @return 工具集
*/
@Bean
- public WorkspaceAgentTools workspaceAgentTools(AgentWorkspaceService workspaceService) {
+ public WorkspaceAgentTools workspaceAgentTools(
+ AgentWorkspaceService workspaceService
+ ) {
return new WorkspaceAgentTools(workspaceService);
}
@@ -138,12 +142,21 @@ public class SolonClawConfig {
* @return CLI 技能提供者
*/
@Bean
- public CliSkillProvider cliSkillProvider(AgentWorkspaceService workspaceService) {
+ public CliSkillProvider cliSkillProvider(
+ AgentWorkspaceService workspaceService,
+ SolonClawProperties properties
+ ) {
String workDir = workspaceService.getWorkspaceDir().getAbsolutePath();
String skillsDir = FileUtil.mkdir(workspaceService.fileInWorkspace("skills")).getAbsolutePath();
- return new CliSkillProvider(workDir)
+ CliSkillProvider cliSkillProvider = new CliSkillProvider(workDir)
.skillPool("@skills", skillsDir);
+
+ cliSkillProvider.getTerminalSkill().setSandboxMode(
+ properties.getAgent().getTools().isSandboxMode()
+ );
+
+ return cliSkillProvider;
}
/**
@@ -191,6 +204,17 @@ public class SolonClawConfig {
return new ChannelRegistry();
}
+ /**
+ * 创建飞书消息发送服务。
+ *
+ * @param properties 项目配置
+ * @return 飞书发送服务
+ */
+ @Bean
+ public FeishuBotSender feishuBotSender(SolonClawProperties properties) {
+ return new FeishuBotSender(properties.getChannels().getFeishu());
+ }
+
/**
* 创建钉钉 token 服务。
*
@@ -244,7 +268,7 @@ public class SolonClawConfig {
channelRegistry,
properties
);
- workspaceJobService.setJobDispatcher(service::submitSystemMessage);
+ workspaceJobService.setJobDispatcher(service::submitVisibleSystemMessage);
return service;
}
@@ -276,6 +300,31 @@ public class SolonClawConfig {
return adapter;
}
+ /**
+ * 创建并注册飞书渠道适配器。
+ *
+ * @param agentRuntimeService Agent 运行时服务
+ * @param feishuBotSender 飞书消息发送服务
+ * @param channelRegistry 渠道注册表
+ * @param properties 项目配置
+ * @return 飞书渠道适配器
+ */
+ @Bean(initMethod = "start", destroyMethod = "stop")
+ public FeishuChannelAdapter feishuChannelAdapter(
+ AgentRuntimeService agentRuntimeService,
+ FeishuBotSender feishuBotSender,
+ ChannelRegistry channelRegistry,
+ SolonClawProperties properties
+ ) {
+ FeishuChannelAdapter adapter = new FeishuChannelAdapter(
+ agentRuntimeService,
+ feishuBotSender,
+ properties.getChannels().getFeishu()
+ );
+ channelRegistry.register(adapter);
+ return adapter;
+ }
+
/**
* 创建心跳服务。
*
diff --git a/src/main/java/com/jimuqu/claw/config/SolonClawProperties.java b/src/main/java/com/jimuqu/claw/config/SolonClawProperties.java
index 3a1359bb1087cb70c24077a2944d72fbb4b77364..4dac46812f5bf0c56ecbaa9e3ff406d5b5fb15e7 100644
--- a/src/main/java/com/jimuqu/claw/config/SolonClawProperties.java
+++ b/src/main/java/com/jimuqu/claw/config/SolonClawProperties.java
@@ -76,6 +76,8 @@ public class SolonClawProperties {
private String systemPrompt;
/** 调度器配置。 */
private Scheduler scheduler = new Scheduler();
+ /** 工具配置。 */
+ private Tools tools = new Tools();
/** 心跳配置。 */
private Heartbeat heartbeat = new Heartbeat();
@@ -115,6 +117,24 @@ public class SolonClawProperties {
this.scheduler = scheduler;
}
+ /**
+ * 返回工具配置。
+ *
+ * @return 工具配置
+ */
+ public Tools getTools() {
+ return tools;
+ }
+
+ /**
+ * 设置工具配置。
+ *
+ * @param tools 工具配置
+ */
+ public void setTools(Tools tools) {
+ this.tools = tools;
+ }
+
/**
* 返回心跳配置。
*
@@ -134,6 +154,32 @@ public class SolonClawProperties {
}
}
+ /**
+ * 描述工具能力配置。
+ */
+ public static class Tools {
+ /** CLI TerminalSkill 是否启用沙盒模式。 */
+ private boolean sandboxMode = true;
+
+ /**
+ * 返回 TerminalSkill 是否启用沙盒模式。
+ *
+ * @return 若启用则返回 true
+ */
+ public boolean isSandboxMode() {
+ return sandboxMode;
+ }
+
+ /**
+ * 设置 TerminalSkill 是否启用沙盒模式。
+ *
+ * @param sandboxMode 启用标记
+ */
+ public void setSandboxMode(boolean sandboxMode) {
+ this.sandboxMode = sandboxMode;
+ }
+ }
+
/**
* 描述并发调度配置。
*/
@@ -230,9 +276,29 @@ public class SolonClawProperties {
* 描述所有渠道配置的聚合对象。
*/
public static class Channels {
+ /** 飞书渠道配置。 */
+ private Feishu feishu = new Feishu();
/** 钉钉渠道配置。 */
private DingTalk dingtalk = new DingTalk();
+ /**
+ * 返回飞书配置。
+ *
+ * @return 飞书配置
+ */
+ public Feishu getFeishu() {
+ return feishu;
+ }
+
+ /**
+ * 设置飞书配置。
+ *
+ * @param feishu 飞书配置
+ */
+ public void setFeishu(Feishu feishu) {
+ this.feishu = feishu;
+ }
+
/**
* 返回钉钉配置。
*
@@ -252,6 +318,152 @@ public class SolonClawProperties {
}
}
+ /**
+ * 描述飞书机器人配置。
+ */
+ public static class Feishu {
+ /** 是否启用飞书渠道。 */
+ private boolean enabled;
+ /** 飞书 appId。 */
+ private String appId = "";
+ /** 飞书 appSecret。 */
+ private String appSecret = "";
+ /** 飞书开放平台域名。 */
+ private String baseDomain = "https://open.feishu.cn";
+ /** 是否启用基于卡片 patch 的流式更新。 */
+ private boolean streamingReply = true;
+ /** 私聊允许列表。 */
+ private List allowFrom = new ArrayList<>();
+ /** 群聊允许列表。 */
+ private List groupAllowFrom = new ArrayList<>();
+
+ /**
+ * 返回飞书渠道启用状态。
+ *
+ * @return 若启用则返回 true
+ */
+ public boolean isEnabled() {
+ return enabled;
+ }
+
+ /**
+ * 设置飞书渠道启用状态。
+ *
+ * @param enabled 启用标记
+ */
+ public void setEnabled(boolean enabled) {
+ this.enabled = enabled;
+ }
+
+ /**
+ * 返回 appId。
+ *
+ * @return appId
+ */
+ public String getAppId() {
+ return appId;
+ }
+
+ /**
+ * 设置 appId。
+ *
+ * @param appId appId
+ */
+ public void setAppId(String appId) {
+ this.appId = appId;
+ }
+
+ /**
+ * 返回 appSecret。
+ *
+ * @return appSecret
+ */
+ public String getAppSecret() {
+ return appSecret;
+ }
+
+ /**
+ * 设置 appSecret。
+ *
+ * @param appSecret appSecret
+ */
+ public void setAppSecret(String appSecret) {
+ this.appSecret = appSecret;
+ }
+
+ /**
+ * 返回开放平台域名。
+ *
+ * @return 开放平台域名
+ */
+ public String getBaseDomain() {
+ return baseDomain;
+ }
+
+ /**
+ * 设置开放平台域名。
+ *
+ * @param baseDomain 开放平台域名
+ */
+ public void setBaseDomain(String baseDomain) {
+ this.baseDomain = baseDomain;
+ }
+
+ /**
+ * 返回是否启用流式更新。
+ *
+ * @return 若启用则返回 true
+ */
+ public boolean isStreamingReply() {
+ return streamingReply;
+ }
+
+ /**
+ * 设置是否启用流式更新。
+ *
+ * @param streamingReply 流式更新标记
+ */
+ public void setStreamingReply(boolean streamingReply) {
+ this.streamingReply = streamingReply;
+ }
+
+ /**
+ * 返回私聊白名单。
+ *
+ * @return 私聊白名单
+ */
+ public List getAllowFrom() {
+ return allowFrom;
+ }
+
+ /**
+ * 设置私聊白名单。
+ *
+ * @param allowFrom 私聊白名单
+ */
+ public void setAllowFrom(List allowFrom) {
+ this.allowFrom = allowFrom;
+ }
+
+ /**
+ * 返回群聊白名单。
+ *
+ * @return 群聊白名单
+ */
+ public List getGroupAllowFrom() {
+ return groupAllowFrom;
+ }
+
+ /**
+ * 设置群聊白名单。
+ *
+ * @param groupAllowFrom 群聊白名单
+ */
+ public void setGroupAllowFrom(List groupAllowFrom) {
+ this.groupAllowFrom = groupAllowFrom;
+ }
+ }
+
/**
* 描述钉钉机器人配置。
*/
diff --git a/src/main/resources/app.yml b/src/main/resources/app.yml
index 4fa590380db44a93fc37e39d0d5c733c9f341f15..eec6bc558d9895e3d60c64bb0a4ba854c077a338 100644
--- a/src/main/resources/app.yml
+++ b/src/main/resources/app.yml
@@ -33,6 +33,7 @@ solonclaw:
## 工作区
- 工作区是默认文件根目录;除非用户明确要求,不要把运行期文件写到别处。
- 用户可编辑的工作区文件会在后文注入;如果存在 AGENTS.md、SOUL.md、USER.md、TOOLS.md、HEARTBEAT.md 等内容,应把它们视为当前运行的重要上下文。
+ - 命令执行过程中产生的临时文件、下载缓存和中间产物,优先放入临时目录;任务结束后应清理无用临时文件,避免污染工作区。
## 心跳
- 如果收到心跳检查且当前没有需要处理的事项,就简洁确认状态正常。
@@ -40,10 +41,23 @@ solonclaw:
scheduler:
maxConcurrentPerConversation: 4
ackWhenBusy: false
+ tools:
+ # CLI TerminalSkill 沙盒开关
+ # true: 只允许工作区相对路径、~/ 和 @skills 逻辑路径;禁止绝对路径与越界访问
+ # false: 允许绝对路径,CLI 能力更开放
+ sandboxMode: true
heartbeat:
enabled: true
intervalSeconds: 1800
channels:
+ feishu:
+ enabled: false
+ appId: ""
+ appSecret: ""
+ baseDomain: "https://open.feishu.cn"
+ streamingReply: true
+ allowFrom: []
+ groupAllowFrom: []
dingtalk:
enabled: false
clientId: ""
diff --git a/src/main/resources/template/AGENTS.md b/src/main/resources/template/AGENTS.md
index 9495331978e952b841eade7b5750ab54ceb6d2dc..962e2515c06498123c2bf8de6fc8729bb1a05099 100644
--- a/src/main/resources/template/AGENTS.md
+++ b/src/main/resources/template/AGENTS.md
@@ -2,6 +2,32 @@
这个文件夹是你的家。请如此对待。
+## 会话启动
+
+在做任何事情之前:
+
+1. 阅读 `SOUL.md` - 这是你的身份和行事方式
+2. 阅读 `IDENTITY.md` - 这是你的名字和外在设定
+3. 阅读 `USER.md` - 这是你要帮助的人
+4. 阅读 `memory/YYYY-MM-DD.md`(今天 + 昨天)获取近期上下文
+5. 如果当前是主会话,再阅读 `MEMORY.md`
+
+不要反复请求许可。先读取上下文,再开始工作。
+
+## 会话类型
+
+### 主会话
+
+- 指与用户本人直接对话的私有会话
+- 可以读取和更新 `MEMORY.md`
+- 可以整理长期偏好、长期约定和稳定事实
+
+### 共享会话
+
+- 指群聊、多人会话、公共渠道或其他可能被多人看到的上下文
+- 默认不要主动读取、引用或泄露 `MEMORY.md` 中的私人长期信息
+- 优先依据当前消息、近期 daily memory 和当前任务上下文来回应
+
## 当前记忆结构
- `MEMORY.md`:长期稳定事实、偏好、约定
@@ -11,51 +37,63 @@
- `runtime/conversations/*`:自动落盘的短期会话历史
- `runtime/meta/*`:回复路由状态,不是语义记忆
-## 会话启动
-
-在做任何事情之前:
-
-1. 阅读 `SOUL.md` — 这是你的身份
-2. 阅读 `IDENTITY.md` — 这是你的名字和外在设定
-2. 阅读 `USER.md` — 这是你要帮助的人
-3. 阅读 `MEMORY.md` — 这是长期稳定记忆
-4. 阅读 `memory/YYYY-MM-DD.md`(今天 + 昨天)获取近期上下文
-
-不要请求许可。直接做。
-
## 记忆
每次会话你都是全新启动。这些文件是你的连续性保障:
-- **每日笔记:** `memory/YYYY-MM-DD.md` — 原始近期记录,面向最近上下文
-- **长期记忆:** `MEMORY.md` — 经过整理的长期有效信息
+- `memory/YYYY-MM-DD.md`:原始近期记录,面向最近上下文
+- `MEMORY.md`:经过整理的长期有效信息
-记录重要的事情。决策、上下文、需要记住的事项。除非被要求保存,否则跳过敏感信息。
+记录重要的事情。决策、上下文、需要记住的事项。除非被明确要求保存,否则跳过敏感信息。
### MEMORY.md
- 记录重要事件、想法、决策、观点、经验教训
- 保持精炼,优先保留长期稳定事实
-- 随着时间推移,回顾你的每日文件并将值得保留的内容更新到 MEMORY.md
+- 只有在主会话中默认读取和更新
+- 随着时间推移,回顾每日文件并将值得保留的内容整理进 `MEMORY.md`
### memory/YYYY-MM-DD.md
- 每天一个文件,文件名格式固定为 `YYYY-MM-DD.md`
-- 只记录近期有用的原始信息、临时事项或当天上下文
+- 记录近期有用的原始信息、临时事项或当天上下文
- 系统默认只读取今天和昨天两个文件
-- 更久远且仍然重要的信息,应整理进 `MEMORY.md`
+- 更久远但仍然重要的信息,应整理进 `MEMORY.md`
### 写下来,不要只放在脑子里
-- "心理笔记"无法在会话重启后保留。文件可以。
-- 当有人说"记住这个" → 更新 `memory/YYYY-MM-DD.md` 或相关文件
-- 当你学到长期有效的教训 → 更新 `MEMORY.md`、`AGENTS.md` 或 `TOOLS.md`
-- 当你犯了错误 → 记录下来,这样未来的你不会重蹈覆辙
-- 文件比短暂上下文更可靠
+- 短暂上下文不会可靠保留,文件才会
+- 当用户说“记住这个”时,更新 `memory/YYYY-MM-DD.md` 或相关文件
+- 当你学到长期有效的教训时,更新 `MEMORY.md`、`AGENTS.md` 或 `TOOLS.md`
+- 当你犯了错误时,记录下来,避免未来重复
+
+## 群聊规则
+
+- 被明确点名、提问或需要你完成任务时,优先响应
+- 只有在你能提供明显增益时再发言
+- 别人已经答清楚时,默认不要重复
+- 不要连续发送多条碎片化回复
+- 参与,不要主导;有价值时说话,没必要时保持安静
+
+## 工作区规则
+
+- 工作区是默认文件根目录
+- 除非用户明确要求,不要把长期运行文件写到工作区之外
+- 命令执行过程中产生的临时文件、下载缓存和中间产物,优先放入临时目录
+- 任务结束后应清理无用临时文件,避免污染工作区
+- 可以在工作区内自由阅读、探索、整理和学习
+
+## Heartbeat
+
+- 收到 heartbeat 时,优先读取 `HEARTBEAT.md`
+- 如果 `HEARTBEAT.md` 为空或没有待办,就简洁返回正常状态
+- heartbeat 可以做轻量后台整理,例如回顾近期 memory、检查待办、维护长期记忆
+- 不要因为 heartbeat 制造无意义的对外打扰
## 红线
- 不要泄露隐私数据。绝对不要。
- 不要在未询问的情况下执行破坏性命令。
- `trash` > `rm`(可恢复胜过永远消失)
+- 对外发送、公开发布、敏感改动前先确认
- 有疑问时,先问。
diff --git a/src/test/java/com/jimuqu/claw/agent/runtime/AgentRuntimeServiceTest.java b/src/test/java/com/jimuqu/claw/agent/runtime/AgentRuntimeServiceTest.java
index d89392813b98df12d08c382b5e30475ff0cac002..f1f50cda97e32e5de083c64347f4c8d5697d1918 100644
--- a/src/test/java/com/jimuqu/claw/agent/runtime/AgentRuntimeServiceTest.java
+++ b/src/test/java/com/jimuqu/claw/agent/runtime/AgentRuntimeServiceTest.java
@@ -4,8 +4,10 @@ import com.jimuqu.claw.agent.channel.ChannelAdapter;
import com.jimuqu.claw.agent.channel.ChannelRegistry;
import com.jimuqu.claw.agent.model.AgentRun;
import com.jimuqu.claw.agent.model.ChannelType;
+import com.jimuqu.claw.agent.model.ConversationEvent;
import com.jimuqu.claw.agent.model.ConversationType;
import com.jimuqu.claw.agent.model.InboundEnvelope;
+import com.jimuqu.claw.agent.model.InboundTriggerType;
import com.jimuqu.claw.agent.model.OutboundEnvelope;
import com.jimuqu.claw.agent.model.ReplyTarget;
import com.jimuqu.claw.agent.model.RunStatus;
@@ -20,6 +22,7 @@ import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.BooleanSupplier;
@@ -486,6 +489,93 @@ class AgentRuntimeServiceTest {
}
}
+ /**
+ * 验证可见系统触发不会被写成用户消息,并会带着系统触发类型进入执行层。
+ */
+ @Test
+ void visibleSystemMessageIsNotPersistedAsUserMessage() throws Exception {
+ RuntimeStoreService store = new RuntimeStoreService(tempDir.toFile());
+ ConversationScheduler scheduler = new ConversationScheduler(1);
+ ChannelRegistry registry = new ChannelRegistry();
+ RecordingChannelAdapter adapter = new RecordingChannelAdapter();
+ registry.register(adapter);
+
+ AtomicReference lastRequest = new AtomicReference();
+ ConversationAgent conversationAgent = (request, progressConsumer) -> {
+ lastRequest.set(request);
+ return "reply-" + request.getCurrentMessage();
+ };
+
+ SolonClawProperties properties = new SolonClawProperties();
+ properties.getAgent().getScheduler().setAckWhenBusy(false);
+
+ try {
+ AgentRuntimeService runtimeService = new AgentRuntimeService(conversationAgent, store, scheduler, registry, properties);
+ runtimeService.submitInbound(inbound("msg-user", "normal-question"));
+ assertTrue(waitUntil(() -> adapter.messages.contains("reply-normal-question"), 5000));
+
+ ReplyTarget replyTarget = new ReplyTarget(ChannelType.DINGTALK, ConversationType.GROUP, "group-1", "user-1");
+ runtimeService.submitVisibleSystemMessage("dingtalk:group:group-1", replyTarget, "scheduled-task");
+
+ assertTrue(waitUntil(() -> adapter.messages.contains("reply-scheduled-task"), 5000));
+ assertEquals(InboundTriggerType.SYSTEM_VISIBLE, lastRequest.get().getCurrentMessageTriggerType());
+
+ List events = store.readConversationEvents("dingtalk:group:group-1");
+ assertEquals("system_event", events.get(2).getEventType());
+ assertEquals("system", events.get(2).getRole());
+ assertEquals("assistant_reply", events.get(3).getEventType());
+ assertEquals(1L, events.get(2).getSourceUserVersion());
+ assertEquals(1L, events.get(3).getSourceUserVersion());
+ } finally {
+ scheduler.shutdown();
+ }
+ }
+
+ /**
+ * 验证只有声明支持进度更新的渠道才会收到运行中的增量内容。
+ */
+ @Test
+ void progressIsDispatchedOnlyToProgressCapableChannel() throws Exception {
+ RuntimeStoreService store = new RuntimeStoreService(tempDir.toFile());
+ ConversationScheduler scheduler = new ConversationScheduler(1);
+ ChannelRegistry registry = new ChannelRegistry();
+ ProgressChannelAdapter adapter = new ProgressChannelAdapter();
+ registry.register(adapter);
+
+ ConversationAgent conversationAgent = (request, progressConsumer) -> {
+ progressConsumer.accept("draft-1");
+ progressConsumer.accept("draft-2");
+ return "final-answer";
+ };
+
+ SolonClawProperties properties = new SolonClawProperties();
+ properties.getAgent().getScheduler().setAckWhenBusy(false);
+
+ try {
+ AgentRuntimeService runtimeService = new AgentRuntimeService(conversationAgent, store, scheduler, registry, properties);
+ InboundEnvelope inboundEnvelope = new InboundEnvelope();
+ inboundEnvelope.setMessageId("msg-feishu");
+ inboundEnvelope.setChannelType(ChannelType.FEISHU);
+ inboundEnvelope.setChannelInstanceId("feishu-default");
+ inboundEnvelope.setSenderId("ou-1");
+ inboundEnvelope.setConversationId("oc-1");
+ inboundEnvelope.setConversationType(ConversationType.GROUP);
+ inboundEnvelope.setContent("hello");
+ inboundEnvelope.setReplyTarget(new ReplyTarget(ChannelType.FEISHU, ConversationType.GROUP, "oc-1", "ou-1"));
+ inboundEnvelope.setReceivedAt(System.currentTimeMillis());
+ inboundEnvelope.setSessionKey("feishu:group:oc-1");
+
+ runtimeService.submitInbound(inboundEnvelope);
+
+ assertTrue(waitUntil(() -> adapter.outbounds.size() >= 3, 5000));
+ assertTrue(adapter.outbounds.get(0).isProgress());
+ assertTrue(adapter.outbounds.get(1).isProgress());
+ assertEquals("final-answer", adapter.outbounds.get(2).getContent());
+ } finally {
+ scheduler.shutdown();
+ }
+ }
+
/**
* 构造一条测试入站消息。
*
@@ -534,9 +624,9 @@ class AgentRuntimeServiceTest {
*/
private static class RecordingChannelAdapter implements ChannelAdapter {
/** 记录发送文本。 */
- private final List messages = new CopyOnWriteArrayList<>();
+ protected final List messages = new CopyOnWriteArrayList<>();
/** 记录完整出站消息。 */
- private final List outbounds = new CopyOnWriteArrayList<>();
+ protected final List outbounds = new CopyOnWriteArrayList<>();
/**
* 返回适配器渠道类型。
@@ -559,4 +649,19 @@ class AgentRuntimeServiceTest {
messages.add(outboundEnvelope.getContent());
}
}
+
+ /**
+ * 支持进度更新的伪渠道适配器。
+ */
+ private static class ProgressChannelAdapter extends RecordingChannelAdapter {
+ @Override
+ public ChannelType channelType() {
+ return ChannelType.FEISHU;
+ }
+
+ @Override
+ public boolean supportsProgressUpdates() {
+ return true;
+ }
+ }
}
diff --git a/src/test/java/com/jimuqu/claw/agent/runtime/VisibleProgressAccumulatorTest.java b/src/test/java/com/jimuqu/claw/agent/runtime/VisibleProgressAccumulatorTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..a967c5e66288be9d2bdfa5a26487a0e613ede59d
--- /dev/null
+++ b/src/test/java/com/jimuqu/claw/agent/runtime/VisibleProgressAccumulatorTest.java
@@ -0,0 +1,35 @@
+package com.jimuqu.claw.agent.runtime;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+class VisibleProgressAccumulatorTest {
+ @Test
+ void ignoresThinkingChunksFromVisibleProgress() {
+ VisibleProgressAccumulator accumulator = new VisibleProgressAccumulator();
+
+ assertNull(accumulator.append("先想想", true, false));
+ assertEquals("你好", accumulator.append("你好", false, false));
+ assertEquals("你好,世界", accumulator.append(",世界", false, false));
+ }
+
+ @Test
+ void mergesIncrementalChunksIntoCumulativeVisibleText() {
+ VisibleProgressAccumulator accumulator = new VisibleProgressAccumulator();
+
+ assertEquals("没问题", accumulator.append("没问题", false, false));
+ assertEquals("没问题,以后我就用 Markdown", accumulator.append(",以后我就用 Markdown", false, false));
+ assertEquals("没问题,以后我就用 Markdown 给你回消息。", accumulator.append(" 给你回消息。", false, false));
+ assertNull(accumulator.append("没问题,以后我就用 Markdown 给你回消息。", false, false));
+ }
+
+ @Test
+ void ignoresToolCallChunksDuringStreaming() {
+ VisibleProgressAccumulator accumulator = new VisibleProgressAccumulator();
+
+ assertNull(accumulator.append("tool_call_payload", false, true));
+ assertEquals("搞定。", accumulator.append("搞定。", false, false));
+ }
+}
diff --git a/src/test/java/com/jimuqu/claw/agent/store/RuntimeStoreServiceTest.java b/src/test/java/com/jimuqu/claw/agent/store/RuntimeStoreServiceTest.java
index 5f4eb711a639e94e71ba8421790496a2a745ef15..efb92fc6afc6c17b49c92486c0edd9469e26b1ac 100644
--- a/src/test/java/com/jimuqu/claw/agent/store/RuntimeStoreServiceTest.java
+++ b/src/test/java/com/jimuqu/claw/agent/store/RuntimeStoreServiceTest.java
@@ -2,8 +2,10 @@ package com.jimuqu.claw.agent.store;
import com.jimuqu.claw.agent.model.AgentRun;
import com.jimuqu.claw.agent.model.ChannelType;
+import com.jimuqu.claw.agent.model.ConversationEvent;
import com.jimuqu.claw.agent.model.ConversationType;
import com.jimuqu.claw.agent.model.InboundEnvelope;
+import com.jimuqu.claw.agent.model.InboundTriggerType;
import com.jimuqu.claw.agent.model.ReplyTarget;
import com.jimuqu.claw.agent.model.RunEvent;
import com.jimuqu.claw.agent.model.RunStatus;
@@ -119,6 +121,59 @@ class RuntimeStoreServiceTest {
assertTrue(history.get(2).getContent().contains("result="));
}
+ /**
+ * 验证可见系统消息会以 system 角色写入会话事件。
+ */
+ @Test
+ void appendInboundConversationEventUsesSystemRoleForVisibleSystemTrigger() {
+ RuntimeStoreService store = new RuntimeStoreService(tempDir.toFile());
+
+ InboundEnvelope user = inbound("session-a", "msg-1", "question");
+ long userVersion = store.appendInboundConversationEvent(user);
+
+ InboundEnvelope system = inbound("session-a", "system-1", "scheduled-task");
+ system.setChannelType(ChannelType.SYSTEM);
+ system.setTriggerType(InboundTriggerType.SYSTEM_VISIBLE);
+ system.setHistoryAnchorVersion(userVersion);
+
+ long systemVersion = store.appendInboundConversationEvent(system);
+ List events = store.readConversationEvents("session-a");
+
+ assertEquals(2L, systemVersion);
+ assertEquals("user_message", events.get(0).getEventType());
+ assertEquals("system_event", events.get(1).getEventType());
+ assertEquals("system", events.get(1).getRole());
+ assertEquals(userVersion, events.get(1).getSourceUserVersion());
+ }
+
+ /**
+ * 验证同一锚点下的系统事件与回复会按事件版本顺序重建。
+ */
+ @Test
+ void loadConversationHistoryBeforeKeepsAnchoredSystemEventOrder() {
+ RuntimeStoreService store = new RuntimeStoreService(tempDir.toFile());
+
+ InboundEnvelope user = inbound("session-a", "msg-1", "question");
+ long userVersion = store.appendInboundConversationEvent(user);
+ store.appendAssistantConversationEvent("session-a", "run-1", "msg-1", userVersion, "reply-user");
+
+ InboundEnvelope system = inbound("session-a", "system-1", "scheduled-task");
+ system.setChannelType(ChannelType.SYSTEM);
+ system.setTriggerType(InboundTriggerType.SYSTEM_VISIBLE);
+ system.setHistoryAnchorVersion(userVersion);
+ long systemVersion = store.appendInboundConversationEvent(system);
+ store.appendAssistantConversationEvent("session-a", "run-2", "system-1", userVersion, "reply-system");
+
+ List history = store.loadConversationHistoryBefore("session-a", systemVersion + 10L);
+
+ assertEquals(4, history.size());
+ assertEquals("question", history.get(0).getContent());
+ assertEquals("reply-user", history.get(1).getContent());
+ assertEquals("SYSTEM", history.get(2).getRole().toString());
+ assertEquals("scheduled-task", history.get(2).getContent());
+ assertEquals("reply-system", history.get(3).getContent());
+ }
+
/**
* 验证可按父运行聚合多个子任务状态。
*/
diff --git a/src/test/java/com/jimuqu/claw/agent/workspace/WorkspacePromptServiceTest.java b/src/test/java/com/jimuqu/claw/agent/workspace/WorkspacePromptServiceTest.java
index d6b034f5b2de23573f7ed483f0a70a8d795a173d..627c68b2ffb2c154e5823c020fa0e117d56b2abb 100644
--- a/src/test/java/com/jimuqu/claw/agent/workspace/WorkspacePromptServiceTest.java
+++ b/src/test/java/com/jimuqu/claw/agent/workspace/WorkspacePromptServiceTest.java
@@ -9,6 +9,7 @@ import java.time.format.DateTimeFormatter;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
@@ -60,6 +61,8 @@ class WorkspacePromptServiceTest {
assertTrue(prompt.contains("用户偏好中文回复"));
assertTrue(prompt.contains("昨天发生了重要事情。"));
assertTrue(prompt.contains("今天需要继续跟进。"));
+ assertFalse(prompt.contains("## 心跳清单"));
+ assertFalse(prompt.contains("# HEARTBEAT"));
assertEquals("Xiaolongxia", promptService.resolveAgentName());
}
diff --git a/src/test/java/com/jimuqu/claw/channel/feishu/FeishuBotSenderTest.java b/src/test/java/com/jimuqu/claw/channel/feishu/FeishuBotSenderTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..ba862ccc246f3f6d4fd74cff5a4f04a397713727
--- /dev/null
+++ b/src/test/java/com/jimuqu/claw/channel/feishu/FeishuBotSenderTest.java
@@ -0,0 +1,84 @@
+package com.jimuqu.claw.channel.feishu;
+
+import com.alibaba.fastjson.JSONObject;
+import com.jimuqu.claw.agent.model.ChannelType;
+import com.jimuqu.claw.agent.model.ConversationType;
+import com.jimuqu.claw.agent.model.OutboundEnvelope;
+import com.jimuqu.claw.agent.model.ReplyTarget;
+import com.jimuqu.claw.config.SolonClawProperties;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class FeishuBotSenderTest {
+ @Test
+ void buildsMarkdownCardPayloadFromContent() {
+ FeishuBotSender sender = new FeishuBotSender(new SolonClawProperties.Feishu(), new RecordingGateway());
+
+ String payload = sender.cardMessageParam("#### 杭州天气\n> 9度,西北风1级");
+ JSONObject root = JSONObject.parseObject(payload);
+
+ assertEquals("2.0", root.getString("schema"));
+ assertEquals("markdown", root.getJSONObject("body")
+ .getJSONArray("elements")
+ .getJSONObject(0)
+ .getString("tag"));
+ assertTrue(root.getJSONObject("body")
+ .getJSONArray("elements")
+ .getJSONObject(0)
+ .getString("content")
+ .contains("9度"));
+ }
+
+ @Test
+ void patchesExistingProgressMessageForSameRun() {
+ SolonClawProperties.Feishu properties = new SolonClawProperties.Feishu();
+ properties.setStreamingReply(true);
+ RecordingGateway gateway = new RecordingGateway();
+ FeishuBotSender sender = new FeishuBotSender(properties, gateway);
+
+ sender.send(outbound("run-1", "thinking-1", true));
+ sender.send(outbound("run-1", "thinking-2", true));
+ sender.send(outbound("run-1", "final-answer", false));
+
+ assertEquals(1, gateway.createdMessageIds.size());
+ assertEquals(2, gateway.patchedMessageIds.size());
+ assertEquals("msg-1", gateway.patchedMessageIds.get(0));
+ assertEquals("msg-1", gateway.patchedMessageIds.get(1));
+ assertTrue(gateway.patchedContents.get(1).contains("final-answer"));
+ }
+
+ private OutboundEnvelope outbound(String runId, String content, boolean progress) {
+ OutboundEnvelope outboundEnvelope = new OutboundEnvelope();
+ outboundEnvelope.setRunId(runId);
+ outboundEnvelope.setContent(content);
+ outboundEnvelope.setProgress(progress);
+ outboundEnvelope.setReplyTarget(new ReplyTarget(ChannelType.FEISHU, ConversationType.GROUP, "oc_demo", "ou_demo"));
+ return outboundEnvelope;
+ }
+
+ private static class RecordingGateway implements FeishuMessageGateway {
+ private final List createdMessageIds = new ArrayList<>();
+ private final List patchedMessageIds = new ArrayList<>();
+ private final List createdContents = new ArrayList<>();
+ private final List patchedContents = new ArrayList<>();
+
+ @Override
+ public String createCardMessage(String chatId, String cardContent) {
+ String messageId = "msg-" + (createdMessageIds.size() + 1);
+ createdMessageIds.add(messageId);
+ createdContents.add(cardContent);
+ return messageId;
+ }
+
+ @Override
+ public void patchCardMessage(String messageId, String cardContent) {
+ patchedMessageIds.add(messageId);
+ patchedContents.add(cardContent);
+ }
+ }
+}
diff --git a/src/test/java/com/jimuqu/claw/channel/feishu/FeishuChannelAdapterTest.java b/src/test/java/com/jimuqu/claw/channel/feishu/FeishuChannelAdapterTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..218c787cb8aff294dad6bea209328b65e69070e4
--- /dev/null
+++ b/src/test/java/com/jimuqu/claw/channel/feishu/FeishuChannelAdapterTest.java
@@ -0,0 +1,111 @@
+package com.jimuqu.claw.channel.feishu;
+
+import com.alibaba.fastjson.JSONObject;
+import com.lark.oapi.service.im.v1.model.EventMessage;
+import com.lark.oapi.service.im.v1.model.EventSender;
+import com.lark.oapi.service.im.v1.model.P2MessageReceiveV1;
+import com.lark.oapi.service.im.v1.model.P2MessageReceiveV1Data;
+import com.lark.oapi.service.im.v1.model.UserId;
+import com.jimuqu.claw.agent.model.ConversationType;
+import com.jimuqu.claw.agent.model.InboundEnvelope;
+import com.jimuqu.claw.config.SolonClawProperties;
+import org.junit.jupiter.api.Test;
+
+import java.util.Collections;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+class FeishuChannelAdapterTest {
+ @Test
+ void mapsGroupMessageIntoGroupSession() {
+ SolonClawProperties.Feishu properties = new SolonClawProperties.Feishu();
+ properties.setGroupAllowFrom(Collections.singletonList("oc-group"));
+
+ FeishuChannelAdapter adapter = new FeishuChannelAdapter(null, null, properties);
+ InboundEnvelope inboundEnvelope = adapter.toInboundEnvelope(messageEvent("oc-group", "ou-1", "group", "text", textContent("群消息")));
+
+ assertNotNull(inboundEnvelope);
+ assertEquals(ConversationType.GROUP, inboundEnvelope.getConversationType());
+ assertEquals("feishu:group:oc-group", inboundEnvelope.getSessionKey());
+ assertEquals("oc-group", inboundEnvelope.getReplyTarget().getConversationId());
+ }
+
+ @Test
+ void mapsPrivateMessageIntoPrivateSession() {
+ SolonClawProperties.Feishu properties = new SolonClawProperties.Feishu();
+ properties.setAllowFrom(Collections.singletonList("ou-private"));
+
+ FeishuChannelAdapter adapter = new FeishuChannelAdapter(null, null, properties);
+ InboundEnvelope inboundEnvelope = adapter.toInboundEnvelope(messageEvent("oc-private", "ou-private", "p2p", "text", textContent("私聊消息")));
+
+ assertNotNull(inboundEnvelope);
+ assertEquals(ConversationType.PRIVATE, inboundEnvelope.getConversationType());
+ assertEquals("feishu:private:oc-private", inboundEnvelope.getSessionKey());
+ assertEquals("ou-private", inboundEnvelope.getReplyTarget().getUserId());
+ }
+
+ @Test
+ void rejectsMessageOutsideAllowListWhenConfigured() {
+ SolonClawProperties.Feishu properties = new SolonClawProperties.Feishu();
+ properties.setGroupAllowFrom(Collections.singletonList("oc-group"));
+
+ FeishuChannelAdapter adapter = new FeishuChannelAdapter(null, null, properties);
+
+ assertNull(adapter.toInboundEnvelope(messageEvent("oc-other", "ou-1", "group", "text", textContent("未授权群"))));
+ }
+
+ @Test
+ void extractsPlainTextFromPostMessage() {
+ SolonClawProperties.Feishu properties = new SolonClawProperties.Feishu();
+
+ FeishuChannelAdapter adapter = new FeishuChannelAdapter(null, null, properties);
+ InboundEnvelope inboundEnvelope = adapter.toInboundEnvelope(messageEvent("oc-post", "ou-2", "group", "post", postContent("标题", "正文")));
+
+ assertNotNull(inboundEnvelope);
+ assertEquals("标题\n正文", inboundEnvelope.getContent());
+ }
+
+ private P2MessageReceiveV1 messageEvent(String chatId, String openId, String chatType, String messageType, String content) {
+ P2MessageReceiveV1 event = new P2MessageReceiveV1();
+ P2MessageReceiveV1Data data = new P2MessageReceiveV1Data();
+ EventMessage message = new EventMessage();
+ message.setMessageId("msg-" + chatId);
+ message.setChatId(chatId);
+ message.setChatType(chatType);
+ message.setMessageType(messageType);
+ message.setContent(content);
+ message.setCreateTime(String.valueOf(System.currentTimeMillis()));
+ data.setMessage(message);
+
+ UserId userId = new UserId();
+ userId.setOpenId(openId);
+ EventSender sender = new EventSender();
+ sender.setSenderId(userId);
+ data.setSender(sender);
+
+ event.setEvent(data);
+ return event;
+ }
+
+ private String textContent(String text) {
+ JSONObject jsonObject = new JSONObject();
+ jsonObject.put("text", text);
+ return jsonObject.toJSONString();
+ }
+
+ private String postContent(String title, String text) {
+ JSONObject textNode = new JSONObject();
+ textNode.put("tag", "text");
+ textNode.put("text", text);
+
+ JSONObject zhCn = new JSONObject();
+ zhCn.put("title", title);
+ zhCn.put("content", Collections.singletonList(Collections.singletonList(textNode)));
+
+ JSONObject post = new JSONObject();
+ post.put("zh_cn", zhCn);
+ return post.toJSONString();
+ }
+}
diff --git a/src/test/java/com/jimuqu/claw/config/SolonClawConfigTest.java b/src/test/java/com/jimuqu/claw/config/SolonClawConfigTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..bc745ce058ec22df9e23eed66e0e57ae8a9a5a99
--- /dev/null
+++ b/src/test/java/com/jimuqu/claw/config/SolonClawConfigTest.java
@@ -0,0 +1,36 @@
+package com.jimuqu.claw.config;
+
+import com.jimuqu.claw.agent.workspace.AgentWorkspaceService;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.noear.solon.ai.skills.cli.CliSkillProvider;
+
+import java.lang.reflect.Field;
+import java.nio.file.Path;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class SolonClawConfigTest {
+ @Test
+ void cliSandboxModeDefaultsToTrue() {
+ SolonClawProperties properties = new SolonClawProperties();
+
+ assertTrue(properties.getAgent().getTools().isSandboxMode());
+ }
+
+ @Test
+ void cliSkillProviderAppliesSandboxMode(@TempDir Path tempDir) throws Exception {
+ SolonClawConfig config = new SolonClawConfig();
+ SolonClawProperties properties = new SolonClawProperties();
+ properties.getAgent().getTools().setSandboxMode(false);
+
+ AgentWorkspaceService workspaceService = new AgentWorkspaceService(tempDir.toString());
+ CliSkillProvider skillProvider = config.cliSkillProvider(workspaceService, properties);
+
+ Field sandboxModeField = skillProvider.getTerminalSkill().getClass().getDeclaredField("sandboxMode");
+ sandboxModeField.setAccessible(true);
+
+ assertFalse((Boolean) sandboxModeField.get(skillProvider.getTerminalSkill()));
+ }
+}