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())); + } +}