From 79a50676a7ae0c5f1b3211237c4b8622e6cd584b Mon Sep 17 00:00:00 2001 From: chengliang Date: Fri, 20 Mar 2026 01:26:52 +0800 Subject: [PATCH 01/10] =?UTF-8?q?chore(config):=20=E5=BF=BD=E7=95=A5?= =?UTF-8?q?=E6=9C=AC=E5=9C=B0=20app-dev=20=E9=85=8D=E7=BD=AE=E6=96=87?= =?UTF-8?q?=E4=BB=B6=20(Ignore=20local=20app-dev=20config)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + src/main/resources/app-dev.yml | 5 ----- 2 files changed, 1 insertion(+), 5 deletions(-) delete mode 100644 src/main/resources/app-dev.yml diff --git a/.gitignore b/.gitignore index 46548e3..3b758ce 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ target/ !.mvn/wrapper/maven-wrapper.jar logs/ workspace/ +src/main/resources/app-dev.yml ### STS ### .apt_generated diff --git a/src/main/resources/app-dev.yml b/src/main/resources/app-dev.yml deleted file mode 100644 index bd85800..0000000 --- a/src/main/resources/app-dev.yml +++ /dev/null @@ -1,5 +0,0 @@ -solon.ai.chat: - default: - apiUrl: "http://127.0.0.1:11434/api/chat" # 使用完整地址(而不是 api_base) - provider: "ollama" # 使用 ollama 服务时,需要配置 provider - model: "qwen3.5:0.8b" # 或 deepseek-r1:7b \ No newline at end of file -- Gitee From 0c9979b272d0fdcc33b872773eafc0787ab691a1 Mon Sep 17 00:00:00 2001 From: chengliang Date: Fri, 20 Mar 2026 01:27:01 +0800 Subject: [PATCH 02/10] =?UTF-8?q?feat(runtime):=20=E5=A2=9E=E5=8A=A0=20ReA?= =?UTF-8?q?ct=20=E7=9B=91=E6=8E=A7=E6=97=A5=E5=BF=97=E4=B8=8E=E6=89=A7?= =?UTF-8?q?=E8=A1=8C=E5=85=9C=E5=BA=95=20(Add=20ReAct=20logging=20and=20ex?= =?UTF-8?q?ecution=20fallback)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../runtime/impl/ReActLoggingInterceptor.java | 106 ++++++++++++++++++ .../impl/SolonAiConversationAgent.java | 75 +++++++++---- .../jimuqu/claw/config/SolonClawConfig.java | 18 ++- .../claw/config/SolonClawConfigTest.java | 11 ++ 4 files changed, 185 insertions(+), 25 deletions(-) create mode 100644 src/main/java/com/jimuqu/claw/agent/runtime/impl/ReActLoggingInterceptor.java diff --git a/src/main/java/com/jimuqu/claw/agent/runtime/impl/ReActLoggingInterceptor.java b/src/main/java/com/jimuqu/claw/agent/runtime/impl/ReActLoggingInterceptor.java new file mode 100644 index 0000000..d8e7215 --- /dev/null +++ b/src/main/java/com/jimuqu/claw/agent/runtime/impl/ReActLoggingInterceptor.java @@ -0,0 +1,106 @@ +package com.jimuqu.claw.agent.runtime.impl; + +import cn.hutool.core.util.StrUtil; +import org.noear.solon.ai.agent.react.ReActInterceptor; +import org.noear.solon.ai.agent.react.ReActTrace; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Map; + +/** + * 为 ReAct 推理过程输出后台监控日志。 + */ +public class ReActLoggingInterceptor implements ReActInterceptor { + + private static final Logger log = LoggerFactory.getLogger(ReActLoggingInterceptor.class); + private static final int MAX_LOG_TEXT_LENGTH = 500; + + @Override + public void onAgentStart(ReActTrace trace) { + log.info( + "[AiAgent] 任务开始, agent={}, session={}, prompt={}", + trace.getAgentName(), + sessionId(trace), + compact(trace.getOriginalPrompt() == null ? null : trace.getOriginalPrompt().getUserContent()) + ); + } + + @Override + public void onThought(ReActTrace trace, String thought) { + if (StrUtil.isBlank(thought)) { + return; + } + + log.info( + "[AiAgent] 思考过程, agent={}, session={}, step={}, content={}", + trace.getAgentName(), + sessionId(trace), + trace.getStepCount(), + compact(thought) + ); + } + + @Override + public void onAction(ReActTrace trace, String toolName, Map args) { + log.info( + "[AiAgent] 调用工具, agent={}, session={}, step={}, tool={}, args={}", + trace.getAgentName(), + sessionId(trace), + trace.getStepCount(), + toolName, + compact(String.valueOf(args)) + ); + } + + @Override + public void onObservation(ReActTrace trace, String toolName, String result, long durationMs) { + log.info( + "[AiAgent] 工具结果, agent={}, session={}, step={}, tool={}, durationMs={}, result={}", + trace.getAgentName(), + sessionId(trace), + trace.getStepCount(), + toolName, + durationMs, + compact(result) + ); + } + + @Override + public void onAgentEnd(ReActTrace trace) { + log.info( + "[AiAgent] 任务结束, agent={}, session={}, steps={}, tools={}, durationMs={}, promptTokens={}, completionTokens={}, totalTokens={}, finalAnswer={}", + trace.getAgentName(), + sessionId(trace), + trace.getStepCount(), + trace.getToolCallCount(), + trace.getMetrics().getTotalDuration(), + trace.getMetrics().getPromptTokens(), + trace.getMetrics().getCompletionTokens(), + trace.getMetrics().getTotalTokens(), + compact(trace.getFinalAnswer()) + ); + + if (log.isDebugEnabled()) { + log.debug( + "[AiAgent] 格式化历史, agent={}, session={}\n{}", + trace.getAgentName(), + sessionId(trace), + trace.getFormattedHistory() + ); + } + } + + private String sessionId(ReActTrace trace) { + return trace.getSession() == null ? "未知会话" : trace.getSession().getSessionId(); + } + + private String compact(String text) { + if (StrUtil.isBlank(text)) { + return ""; + } + + String normalized = text.replace("\r", " ").replace("\n", "\\n").trim(); + return StrUtil.maxLength(normalized, MAX_LOG_TEXT_LENGTH); + } +} diff --git a/src/main/java/com/jimuqu/claw/agent/runtime/impl/SolonAiConversationAgent.java b/src/main/java/com/jimuqu/claw/agent/runtime/impl/SolonAiConversationAgent.java index c4e1e08..ff93f0a 100644 --- a/src/main/java/com/jimuqu/claw/agent/runtime/impl/SolonAiConversationAgent.java +++ b/src/main/java/com/jimuqu/claw/agent/runtime/impl/SolonAiConversationAgent.java @@ -12,9 +12,12 @@ import com.jimuqu.claw.agent.workspace.WorkspacePromptService; import cn.hutool.core.util.StrUtil; import org.noear.solon.ai.agent.AgentChunk; import org.noear.solon.ai.agent.react.ReActAgent; +import org.noear.solon.ai.agent.react.ReActInterceptor; import org.noear.solon.ai.chat.ChatModel; import org.noear.solon.ai.chat.message.ChatMessage; import org.noear.solon.ai.skills.cli.CliSkillProvider; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import reactor.core.publisher.Flux; import java.util.concurrent.atomic.AtomicReference; @@ -24,44 +27,62 @@ import java.util.function.Consumer; * 基于 Solon AI ReActAgent 的会话执行实现。 */ public class SolonAiConversationAgent implements ConversationAgent { - /** 聊天模型。 */ + + private static final Logger log = LoggerFactory.getLogger(SolonAiConversationAgent.class); + /** + * 聊天模型。 + */ private final ChatModel chatModel; - /** 工作区提示词服务。 */ + /** + * 工作区提示词服务。 + */ private final WorkspacePromptService workspacePromptService; - /** 工作区工具集。 */ + /** + * 工作区工具集。 + */ private final WorkspaceAgentTools workspaceAgentTools; - /** CLI 技能提供者。 */ + /** + * CLI 技能提供者。 + */ private final CliSkillProvider cliSkillProvider; - /** 定时任务工具。 */ + /** + * 定时任务工具。 + */ private final JobTools jobTools; + /** + * ReAct 运行日志拦截器。 + */ + private final ReActInterceptor reActInterceptor; /** * 创建基于聊天模型的会话执行 Agent。 * - * @param chatModel 聊天模型 + * @param chatModel 聊天模型 * @param workspacePromptService 工作区提示词服务 - * @param workspaceAgentTools 工作区工具集 - * @param cliSkillProvider CLI 技能提供者 - * @param jobTools 定时任务工具 + * @param workspaceAgentTools 工作区工具集 + * @param cliSkillProvider CLI 技能提供者 + * @param jobTools 定时任务工具 */ public SolonAiConversationAgent( ChatModel chatModel, WorkspacePromptService workspacePromptService, WorkspaceAgentTools workspaceAgentTools, CliSkillProvider cliSkillProvider, - JobTools jobTools + JobTools jobTools, + ReActInterceptor reActInterceptor ) { this.chatModel = chatModel; this.workspacePromptService = workspacePromptService; this.workspaceAgentTools = workspaceAgentTools; this.cliSkillProvider = cliSkillProvider; this.jobTools = jobTools; + this.reActInterceptor = reActInterceptor; } /** * 执行一次对话请求。 * - * @param request 会话执行请求 + * @param request 会话执行请求 * @param progressConsumer 进度回调 * @return 最终回复内容 * @throws Throwable 流式执行过程中的异常 @@ -77,23 +98,30 @@ public class SolonAiConversationAgent implements ConversationAgent { VisibleProgressAccumulator progressAccumulator = new VisibleProgressAccumulator(); String prompt = resolvePrompt(request, session); + Flux stream = buildAgent(request) .prompt(prompt) .session(session) .stream(); - AgentChunk finalChunk = stream.doOnNext(chunk -> { - 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(); + AgentChunk finalChunk; + try { + finalChunk = stream.doOnNext(chunk -> { + 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(); + } catch (Exception e) { + log.error("Failed to execute conversation: {}", e.getMessage(), e); + return "执行会话失败:" + e.getMessage(); + } if (finalChunk == null) { return latestChunk.get(); @@ -120,6 +148,7 @@ public class SolonAiConversationAgent implements ConversationAgent { .defaultToolAdd(runtimeTools) .defaultToolAdd(jobTools) .defaultSkillAdd(cliSkillProvider) + .defaultInterceptorAdd(reActInterceptor) .maxSteps(50) .retryConfig(5, 1000L) .sessionWindowSize(64) diff --git a/src/main/java/com/jimuqu/claw/config/SolonClawConfig.java b/src/main/java/com/jimuqu/claw/config/SolonClawConfig.java index ba510b5..d321f3c 100644 --- a/src/main/java/com/jimuqu/claw/config/SolonClawConfig.java +++ b/src/main/java/com/jimuqu/claw/config/SolonClawConfig.java @@ -7,6 +7,7 @@ import com.jimuqu.claw.agent.runtime.impl.AgentRuntimeService; import com.jimuqu.claw.agent.runtime.api.ConversationAgent; import com.jimuqu.claw.agent.runtime.impl.ConversationScheduler; import com.jimuqu.claw.agent.runtime.impl.HeartbeatService; +import com.jimuqu.claw.agent.runtime.impl.ReActLoggingInterceptor; import com.jimuqu.claw.agent.runtime.impl.SolonAiConversationAgent; import com.jimuqu.claw.agent.store.RuntimeStoreService; import com.jimuqu.claw.agent.tool.JobTools; @@ -18,6 +19,7 @@ import com.jimuqu.claw.channel.dingtalk.adapter.DingTalkChannelAdapter; import com.jimuqu.claw.channel.dingtalk.sender.DingTalkRobotSender; import com.jimuqu.claw.channel.feishu.sender.FeishuBotSender; import com.jimuqu.claw.channel.feishu.adapter.FeishuChannelAdapter; +import org.noear.solon.ai.agent.react.ReActInterceptor; import org.noear.solon.ai.skills.cli.CliSkillProvider; import org.noear.solon.ai.chat.ChatModel; import org.noear.solon.annotation.Bean; @@ -170,6 +172,16 @@ public class SolonClawConfig { return new ConversationScheduler(properties.getAgent().getScheduler().getMaxConcurrentPerConversation()); } + /** + * 创建 ReAct 运行日志拦截器。 + * + * @return ReAct 日志拦截器 + */ + @Bean + public ReActInterceptor reActLoggingInterceptor() { + return new ReActLoggingInterceptor(); + } + /** * 创建会话执行 Agent。 * @@ -183,14 +195,16 @@ public class SolonClawConfig { WorkspacePromptService workspacePromptService, WorkspaceAgentTools workspaceAgentTools, CliSkillProvider cliSkillProvider, - JobTools jobTools + JobTools jobTools, + ReActInterceptor reActLoggingInterceptor ) { return new SolonAiConversationAgent( chatModel, workspacePromptService, workspaceAgentTools, cliSkillProvider, - jobTools + jobTools, + reActLoggingInterceptor ); } diff --git a/src/test/java/com/jimuqu/claw/config/SolonClawConfigTest.java b/src/test/java/com/jimuqu/claw/config/SolonClawConfigTest.java index bc745ce..3d2c39e 100644 --- a/src/test/java/com/jimuqu/claw/config/SolonClawConfigTest.java +++ b/src/test/java/com/jimuqu/claw/config/SolonClawConfigTest.java @@ -1,8 +1,10 @@ package com.jimuqu.claw.config; import com.jimuqu.claw.agent.workspace.AgentWorkspaceService; +import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import org.noear.solon.ai.agent.react.ReActInterceptor; import org.noear.solon.ai.skills.cli.CliSkillProvider; import java.lang.reflect.Field; @@ -33,4 +35,13 @@ class SolonClawConfigTest { assertFalse((Boolean) sandboxModeField.get(skillProvider.getTerminalSkill())); } + + @Test + void reactLoggingInterceptorBeanCreated() { + SolonClawConfig config = new SolonClawConfig(); + + ReActInterceptor interceptor = config.reActLoggingInterceptor(); + + Assertions.assertNotNull(interceptor); + } } -- Gitee From a73b3c20929ea5fd7cda65d495af6397de5cdf65 Mon Sep 17 00:00:00 2001 From: chengliang Date: Fri, 20 Mar 2026 01:27:09 +0800 Subject: [PATCH 03/10] =?UTF-8?q?fix(dingtalk):=20=E9=99=90=E5=88=B6=20mar?= =?UTF-8?q?kdown=20=E6=B6=88=E6=81=AF=E9=95=BF=E5=BA=A6=E4=B8=BA=E4=BA=94?= =?UTF-8?q?=E5=8D=83=20(Limit=20markdown=20message=20length=20to=205000)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dingtalk/sender/DingTalkRobotSender.java | 45 ++++++++++++++++--- .../dingtalk/DingTalkRobotSenderTest.java | 32 +++++++++++++ 2 files changed, 71 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/jimuqu/claw/channel/dingtalk/sender/DingTalkRobotSender.java b/src/main/java/com/jimuqu/claw/channel/dingtalk/sender/DingTalkRobotSender.java index 17002c5..76db37d 100644 --- a/src/main/java/com/jimuqu/claw/channel/dingtalk/sender/DingTalkRobotSender.java +++ b/src/main/java/com/jimuqu/claw/channel/dingtalk/sender/DingTalkRobotSender.java @@ -1,6 +1,7 @@ package com.jimuqu.claw.channel.dingtalk.sender; import cn.hutool.core.util.StrUtil; +import cn.hutool.json.JSONUtil; import com.alibaba.fastjson.JSONObject; import com.aliyun.dingtalkrobot_1_0.Client; import com.aliyun.dingtalkrobot_1_0.models.BatchSendOTOHeaders; @@ -16,7 +17,6 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.util.Collections; -import java.util.List; import java.util.regex.Pattern; /** @@ -24,6 +24,8 @@ import java.util.regex.Pattern; */ public class DingTalkRobotSender { private static final Pattern MARKDOWN_PREFIX = Pattern.compile("^[#>*`\\-\\s]+"); + private static final int MAX_MARKDOWN_TEXT_LENGTH = 5000; + private static final String TRUNCATED_SUFFIX = "\n\n[消息过长,已截断]"; /** 日志记录器。 */ private static final Logger log = LoggerFactory.getLogger(DingTalkRobotSender.class); /** access_token 服务。 */ @@ -71,7 +73,8 @@ public class DingTalkRobotSender { * @param content 文本内容 */ public void sendText(ReplyTarget replyTarget, String content) { - if (replyTarget == null || StrUtil.isBlank(content)) { + String normalizedContent = normalizeMarkdownContent(content); + if (replyTarget == null || StrUtil.isBlank(normalizedContent)) { return; } @@ -86,10 +89,18 @@ public class DingTalkRobotSender { } try { + if (!StrUtil.equals(content, normalizedContent)) { + log.warn( + "Trim DingTalk markdown message to fit length limit. originalLength={}, finalLength={}", + content == null ? 0 : content.length(), + normalizedContent.length() + ); + } + if (replyTarget.getConversationType() == ConversationType.GROUP) { - sendGroup(replyTarget, content); + sendGroup(replyTarget, normalizedContent); } else { - sendPrivate(replyTarget, content); + sendPrivate(replyTarget, normalizedContent); } } catch (Exception exception) { log.warn("Failed to send DingTalk message: {}", exception.getMessage(), exception); @@ -143,6 +154,7 @@ public class DingTalkRobotSender { request.setUserIds(Collections.singletonList(replyTarget.getUserId())); request.setMsgParam(messageParam(content)); + log.info("发送钉钉消息: {}", JSONUtil.toJsonPrettyStr(request)); robotClient.batchSendOTOWithOptions(request, headers, new RuntimeOptions()); } @@ -173,12 +185,33 @@ public class DingTalkRobotSender { * @return JSON 字符串 */ public String markdownMessageParam(String content) { + String normalizedContent = normalizeMarkdownContent(content); JSONObject jsonObject = new JSONObject(); - jsonObject.put("title", resolveMarkdownTitle(content)); - jsonObject.put("text", content); + jsonObject.put("title", resolveMarkdownTitle(normalizedContent)); + jsonObject.put("text", normalizedContent); return jsonObject.toJSONString(); } + /** + * 将 markdown 文本裁剪到钉钉机器人安全范围内。 + * + * @param content 原始文本 + * @return 适合发送的 markdown 文本 + */ + public String normalizeMarkdownContent(String content) { + String normalized = StrUtil.blankToDefault(content, ""); + if (normalized.isEmpty()) { + return normalized; + } + + if (normalized.length() <= MAX_MARKDOWN_TEXT_LENGTH) { + return normalized; + } + + int maxBodyLength = Math.max(0, MAX_MARKDOWN_TEXT_LENGTH - TRUNCATED_SUFFIX.length()); + return normalized.substring(0, maxBodyLength) + TRUNCATED_SUFFIX; + } + /** * 从正文中提取 markdown 标题。 * diff --git a/src/test/java/com/jimuqu/claw/channel/dingtalk/DingTalkRobotSenderTest.java b/src/test/java/com/jimuqu/claw/channel/dingtalk/DingTalkRobotSenderTest.java index af3d819..75f52f1 100644 --- a/src/test/java/com/jimuqu/claw/channel/dingtalk/DingTalkRobotSenderTest.java +++ b/src/test/java/com/jimuqu/claw/channel/dingtalk/DingTalkRobotSenderTest.java @@ -27,6 +27,38 @@ class DingTalkRobotSenderTest { assertEquals("SolonClaw", sender.resolveMarkdownTitle(" ")); } + + @Test + void truncatesMarkdownPayloadWhenCharacterCountExceedsLimit() throws Exception { + DingTalkRobotSender sender = new DingTalkRobotSender(null, new DingTalkProperties(), null); + String content = repeat("a", 7000); + + String payload = sender.markdownMessageParam(content); + JSONObject json = JSONObject.parseObject(payload); + String text = json.getString("text"); + + assertTrue(text.contains("已截断")); + assertTrue(text.length() <= 5000); + } + + @Test + void truncatesMarkdownPayloadBySimpleLengthLimit() throws Exception { + DingTalkRobotSender sender = new DingTalkRobotSender(null, new DingTalkProperties(), null); + String content = repeat("中", 6500); + + String normalized = sender.normalizeMarkdownContent(content); + + assertTrue(normalized.contains("已截断")); + assertTrue(normalized.length() <= 5000); + } + + private String repeat(String value, int count) { + StringBuilder builder = new StringBuilder(value.length() * count); + for (int i = 0; i < count; i++) { + builder.append(value); + } + return builder.toString(); + } } -- Gitee From 1c136b0b8d1aabd764da33ea60a535247944bdeb Mon Sep 17 00:00:00 2001 From: chengliang Date: Fri, 20 Mar 2026 02:29:51 +0800 Subject: [PATCH 04/10] =?UTF-8?q?fix(runtime):=20=E6=94=B6=E6=95=9B?= =?UTF-8?q?=E9=9D=99=E9=BB=98=E7=B3=BB=E7=BB=9F=E4=BA=8B=E4=BB=B6=E5=B9=B6?= =?UTF-8?q?=E8=A1=A5=E9=BD=90=E5=AE=9A=E6=97=B6=E6=8F=90=E9=86=92=E5=85=9C?= =?UTF-8?q?=E5=BA=95=E5=8F=91=E9=80=81=20(Tighten=20silent=20system=20even?= =?UTF-8?q?ts=20and=20fallback=20reminder=20delivery)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../claw/agent/job/WorkspaceJobService.java | 28 ++++++- .../runtime/impl/AgentRuntimeService.java | 57 +++++++++++++ .../impl/SolonAiConversationAgent.java | 53 ++++++++++-- .../jimuqu/claw/config/SolonClawConfig.java | 2 +- .../runtime/AgentRuntimeServiceTest.java | 83 +++++++++++++++++++ 5 files changed, 214 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/jimuqu/claw/agent/job/WorkspaceJobService.java b/src/main/java/com/jimuqu/claw/agent/job/WorkspaceJobService.java index cba325d..0d89334 100644 --- a/src/main/java/com/jimuqu/claw/agent/job/WorkspaceJobService.java +++ b/src/main/java/com/jimuqu/claw/agent/job/WorkspaceJobService.java @@ -139,7 +139,7 @@ public class WorkspaceJobService { if (replyTarget == null || StrUtil.isBlank(definition.getSessionKey()) || jobDispatcher == null) { return; } - jobDispatcher.dispatch(definition.getSessionKey(), replyTarget, definition.getPrompt()); + jobDispatcher.dispatch(definition.getSessionKey(), replyTarget, buildExecutionPrompt(definition)); if (isOneShot(definition)) { removeJob(definition.getName()); } @@ -218,5 +218,31 @@ public class WorkspaceJobService { private boolean isOneShot(JobDefinition definition) { return "once_delay".equals(definition.getMode()); } + + /** + * 将定时任务执行包装成明确的内部事件,避免模型把触发内容误解为新的建任务请求。 + * + * @param definition 定时任务定义 + * @return 运行时提交给 Agent 的内部提示 + */ + private String buildExecutionPrompt(JobDefinition definition) { + StringBuilder builder = new StringBuilder(); + builder.append("[内部定时任务触发]").append('\n'); + builder.append("任务名称: ").append(StrUtil.blankToDefault(definition.getName(), "(未命名)")).append('\n'); + builder.append("调度模式: ").append(StrUtil.blankToDefault(definition.getMode(), "(未知)")).append('\n'); + if (StrUtil.isNotBlank(definition.getScheduleValue())) { + builder.append("调度值: ").append(definition.getScheduleValue()).append('\n'); + } + builder.append("提醒内容:").append('\n'); + builder.append(StrUtil.blankToDefault(definition.getPrompt(), "(空)")).append('\n'); + builder.append('\n'); + builder.append("处理规则:").append('\n'); + builder.append("- 这是一个已经存在的定时提醒正在触发,不是用户要求新建、修改、删除或查询定时任务。").append('\n'); + builder.append("- 请把这条提醒自然、友好地告知用户。优先调用 notify_user 发送提醒;发送后返回 NO_REPLY。").append('\n'); + builder.append("- 如果你直接产出了面向用户的提醒文案,运行时会代为发送一次;不要再重复解释内部触发过程。").append('\n'); + builder.append("- 不要调用 add_job、remove_job、start_job、stop_job、list_jobs、get_job。").append('\n'); + builder.append("- 如果本次无需对外提醒,也直接返回 NO_REPLY。"); + return builder.toString(); + } } diff --git a/src/main/java/com/jimuqu/claw/agent/runtime/impl/AgentRuntimeService.java b/src/main/java/com/jimuqu/claw/agent/runtime/impl/AgentRuntimeService.java index fb9f9d6..2a9f0a4 100644 --- a/src/main/java/com/jimuqu/claw/agent/runtime/impl/AgentRuntimeService.java +++ b/src/main/java/com/jimuqu/claw/agent/runtime/impl/AgentRuntimeService.java @@ -423,6 +423,7 @@ public class AgentRuntimeService { if (!suppressReply && finalReplyOnce && StrUtil.isNotBlank(childCompletionParentRunId)) { runtimeStoreService.appendRunEvent(childCompletionParentRunId, "children_aggregated", "aggregateRunId=" + runId); } + boolean silentReminderDelivered = maybeDeliverSilentReminder(runId, inboundEnvelope, visibleResponse); log.info("Run {} succeeded for session {}", runId, inboundEnvelope.getSessionKey()); handleChildRunCompletion(run); @@ -442,6 +443,14 @@ public class AgentRuntimeService { inboundEnvelope.getReplyTarget().getConversationType(), inboundEnvelope.getReplyTarget().getConversationId() ); + } else if (silentReminderDelivered) { + log.info( + "Run {} reminder dispatched via silent fallback. channelType={}, conversationType={}, conversationId={}", + runId, + inboundEnvelope.getReplyTarget().getChannelType(), + inboundEnvelope.getReplyTarget().getConversationType(), + inboundEnvelope.getReplyTarget().getConversationId() + ); } } catch (Throwable throwable) { run.setStatus(RunStatus.FAILED); @@ -917,6 +926,54 @@ public class AgentRuntimeService { return senderId.startsWith(prefix) ? senderId.substring(prefix.length()) : null; } + /** + * 对定时提醒类的静默系统触发提供一次运行时兜底投递。 + * 当模型没有显式调用 notify_user、但给出了面向用户的提醒文案时,由运行时代发一次。 + * + * @param runId 运行任务标识 + * @param inboundEnvelope 入站消息 + * @param visibleResponse 模型最终可见回复 + * @return 是否已通过兜底逻辑投递 + */ + private boolean maybeDeliverSilentReminder(String runId, InboundEnvelope inboundEnvelope, String visibleResponse) { + if (!isSilentReminderTrigger(inboundEnvelope)) { + return false; + } + if (StrUtil.isBlank(visibleResponse) || isNoReply(visibleResponse)) { + return false; + } + if (runtimeStoreService.hasRunEventType(runId, "notify") + || runtimeStoreService.hasRunEventType(runId, "notify_progress")) { + return false; + } + if (inboundEnvelope.getReplyTarget() == null || inboundEnvelope.getReplyTarget().isDebugWeb()) { + return false; + } + + OutboundEnvelope outboundEnvelope = new OutboundEnvelope(); + outboundEnvelope.setRunId(runId); + outboundEnvelope.setReplyTarget(inboundEnvelope.getReplyTarget()); + outboundEnvelope.setContent(visibleResponse); + channelRegistry.send(outboundEnvelope); + runtimeStoreService.appendRunEvent(runId, "silent_notify_fallback", visibleResponse); + return true; + } + + /** + * 判断当前静默系统消息是否来自定时提醒触发。 + * + * @param inboundEnvelope 入站消息 + * @return 若为定时提醒触发则返回 true + */ + private boolean isSilentReminderTrigger(InboundEnvelope inboundEnvelope) { + return inboundEnvelope != null + && inboundEnvelope.getTriggerType() == InboundTriggerType.SYSTEM_SILENT + && StrUtil.contains( + StrUtil.blankToDefault(inboundEnvelope.getContent(), ""), + "[内部定时任务触发]" + ); + } + } diff --git a/src/main/java/com/jimuqu/claw/agent/runtime/impl/SolonAiConversationAgent.java b/src/main/java/com/jimuqu/claw/agent/runtime/impl/SolonAiConversationAgent.java index ff93f0a..5ae5341 100644 --- a/src/main/java/com/jimuqu/claw/agent/runtime/impl/SolonAiConversationAgent.java +++ b/src/main/java/com/jimuqu/claw/agent/runtime/impl/SolonAiConversationAgent.java @@ -142,17 +142,21 @@ public class SolonAiConversationAgent implements ConversationAgent { request == null ? null : request.getRunQuerySupport(), request == null ? null : request.getNotificationSupport() ); - return ReActAgent.of(chatModel) + ReActAgent.Builder builder = ReActAgent.of(chatModel) .name(workspacePromptService.resolveAgentName()) .instruction(workspacePromptService.buildSystemPrompt(request)) .defaultToolAdd(runtimeTools) - .defaultToolAdd(jobTools) .defaultSkillAdd(cliSkillProvider) .defaultInterceptorAdd(reActInterceptor) .maxSteps(50) .retryConfig(5, 1000L) - .sessionWindowSize(64) - .build(); + .sessionWindowSize(64); + + if (shouldEnableJobTools(request)) { + builder.defaultToolAdd(jobTools); + } + + return builder.build(); } /** @@ -174,11 +178,46 @@ public class SolonAiConversationAgent implements ConversationAgent { } if (triggerType == InboundTriggerType.SYSTEM_SILENT) { - return "这是一次静默内部检查。请结合最新的 system 消息和既有上下文继续处理,不要把它当作用户新消息。" - + "如果当前没有需要对外说明的事项,请返回简洁状态或 NO_REPLY。"; + if (isScheduledReminderTrigger(currentMessage)) { + return "一个定时提醒已触发。提醒内容见最新的 system 消息。" + + "请把这条提醒自然友好地告知用户,不要把它当作新的用户消息。" + + "如果你已经通过 notify_user 发送了提醒,请返回 NO_REPLY。" + + "如果你直接给出面向用户的提醒文案,运行时会代为发送一次。" + + "除非用户明确要求,否则不要解释内部触发过程。"; + } + + return "这是一条内部事件,相关结果见最新的 system 消息。" + + "请先在内部处理,不要把它当作新的用户消息。" + + "除非用户明确要求,否则不要把这次内部处理过程转告用户。" + + "如果没有需要面向用户的后续动作,请直接返回 NO_REPLY。"; } - return "这是一次内部系统触发。请优先依据最新的 system 消息和既有上下文继续处理,不要把它当作用户新消息。"; + return "这是一条内部系统事件,相关内容见最新的 system 消息。" + + "请结合既有上下文继续处理,不要把它当作新的用户消息。" + + "只有在确实需要用户看到结果时,才直接给出面向用户的最终回复。"; + } + + /** + * 静默系统触发只用于内部检查或定时任务执行,不应再次管理定时任务本身。 + * + * @param request 当前执行请求 + * @return 是否挂载定时任务管理工具 + */ + private boolean shouldEnableJobTools(ConversationExecutionRequest request) { + if (request == null) { + return true; + } + return request.getCurrentMessageTriggerType() != InboundTriggerType.SYSTEM_SILENT; + } + + /** + * 判断当前静默事件是否为定时提醒触发。 + * + * @param currentMessage 当前消息文本 + * @return 若为定时提醒触发则返回 true + */ + private boolean isScheduledReminderTrigger(String currentMessage) { + return StrUtil.contains(StrUtil.blankToDefault(currentMessage, ""), "[内部定时任务触发]"); } } diff --git a/src/main/java/com/jimuqu/claw/config/SolonClawConfig.java b/src/main/java/com/jimuqu/claw/config/SolonClawConfig.java index d321f3c..e2bde32 100644 --- a/src/main/java/com/jimuqu/claw/config/SolonClawConfig.java +++ b/src/main/java/com/jimuqu/claw/config/SolonClawConfig.java @@ -282,7 +282,7 @@ public class SolonClawConfig { channelRegistry, properties ); - workspaceJobService.setJobDispatcher(service::submitVisibleSystemMessage); + workspaceJobService.setJobDispatcher(service::submitSilentSystemMessage); return service; } 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 f319013..7dfbe55 100644 --- a/src/test/java/com/jimuqu/claw/agent/runtime/AgentRuntimeServiceTest.java +++ b/src/test/java/com/jimuqu/claw/agent/runtime/AgentRuntimeServiceTest.java @@ -362,6 +362,89 @@ class AgentRuntimeServiceTest { } } + /** + * 验证静默系统触发可主动通知用户,但不会把最终回答再次作为普通回复对外发送。 + * + * @throws Exception 执行异常 + */ + @Test + void silentSystemMessageDoesNotDoubleSendAfterNotifyUser() throws Exception { + RuntimeStoreService store = new RuntimeStoreService(tempDir.toFile()); + ConversationScheduler scheduler = new ConversationScheduler(1); + ChannelRegistry registry = new ChannelRegistry(); + RecordingChannelAdapter adapter = new RecordingChannelAdapter(); + registry.register(adapter); + + ConversationAgent conversationAgent = (request, progressConsumer) -> { + assertEquals(InboundTriggerType.SYSTEM_SILENT, request.getCurrentMessageTriggerType()); + NotificationResult result = request.getNotificationSupport().notifyUser("静默提醒", false); + assertTrue(result.isDelivered()); + return "已发送提醒"; + }; + + SolonClawProperties properties = new SolonClawProperties(); + properties.getAgent().getScheduler().setMaxConcurrentPerConversation(1); + properties.getAgent().getScheduler().setAckWhenBusy(false); + + try { + AgentRuntimeService runtimeService = new AgentRuntimeService(conversationAgent, store, scheduler, registry, properties); + ReplyTarget replyTarget = new ReplyTarget(ChannelType.DINGTALK, ConversationType.GROUP, "group-1", "user-1"); + store.rememberReplyTarget("dingtalk:group:group-1", replyTarget); + + String runId = runtimeService.submitSilentSystemMessage("dingtalk:group:group-1", replyTarget, "内部提醒"); + assertNotNull(runId); + assertTrue(waitUntil(() -> adapter.messages.contains("静默提醒"), 5000)); + + assertEquals(1, adapter.outbounds.size()); + assertEquals("静默提醒", adapter.outbounds.get(0).getContent()); + assertTrue(store.readConversationEvents("dingtalk:group:group-1").isEmpty()); + } finally { + scheduler.shutdown(); + } + } + + /** + * 验证定时提醒类的静默系统触发在未显式 notify_user 时,会把面向用户的提醒文案兜底发送一次。 + * + * @throws Exception 执行异常 + */ + @Test + void silentReminderCanFallbackToSingleOutboundDelivery() throws Exception { + RuntimeStoreService store = new RuntimeStoreService(tempDir.toFile()); + ConversationScheduler scheduler = new ConversationScheduler(1); + ChannelRegistry registry = new ChannelRegistry(); + RecordingChannelAdapter adapter = new RecordingChannelAdapter(); + registry.register(adapter); + + ConversationAgent conversationAgent = (request, progressConsumer) -> { + assertEquals(InboundTriggerType.SYSTEM_SILENT, request.getCurrentMessageTriggerType()); + return "活动一下胳膊吧,顺便转转脖子,休息一下眼睛。"; + }; + + SolonClawProperties properties = new SolonClawProperties(); + properties.getAgent().getScheduler().setMaxConcurrentPerConversation(1); + properties.getAgent().getScheduler().setAckWhenBusy(false); + + try { + AgentRuntimeService runtimeService = new AgentRuntimeService(conversationAgent, store, scheduler, registry, properties); + ReplyTarget replyTarget = new ReplyTarget(ChannelType.DINGTALK, ConversationType.GROUP, "group-1", "user-1"); + store.rememberReplyTarget("dingtalk:group:group-1", replyTarget); + + String content = "[内部定时任务触发]\n" + + "任务名称: 活动一下\n" + + "提醒内容:\n提醒用户活动一下胳膊,伸展一下"; + String runId = runtimeService.submitSilentSystemMessage("dingtalk:group:group-1", replyTarget, content); + assertNotNull(runId); + assertTrue(waitUntil(() -> adapter.messages.contains("活动一下胳膊吧,顺便转转脖子,休息一下眼睛。"), 5000)); + + assertEquals(1, adapter.outbounds.size()); + assertEquals("活动一下胳膊吧,顺便转转脖子,休息一下眼睛。", adapter.outbounds.get(0).getContent()); + assertTrue(store.hasRunEventType(runId, "silent_notify_fallback")); + } finally { + scheduler.shutdown(); + } + } + /** * 验证可按父运行聚合多个子任务,并判断是否全部完成。 * -- Gitee From e49a0e7c318c3a78351b6b1a564454a9b7bde677 Mon Sep 17 00:00:00 2001 From: chengliang Date: Fri, 20 Mar 2026 10:02:34 +0800 Subject: [PATCH 05/10] =?UTF-8?q?refactor(runtime):=20=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E4=B8=BA=20job=20=E5=8F=8C=E9=80=9A=E9=81=93=E4=B8=8E=E6=9D=A5?= =?UTF-8?q?=E6=BA=90=E5=88=86=E6=B5=81=E6=89=A7=E8=A1=8C=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=20(Refactor=20runtime=20into=20dual-path=20jobs=20and=20source?= =?UTF-8?q?-kind=20routing)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pom.xml | 2 +- .../jimuqu/claw/agent/job/AgentTurnSpec.java | 26 + .../jimuqu/claw/agent/job/JobDefinition.java | 11 +- .../claw/agent/job/JobDeliveryMode.java | 13 + .../jimuqu/claw/agent/job/JobPayloadKind.java | 11 + .../claw/agent/job/JobSessionTarget.java | 11 + .../jimuqu/claw/agent/job/JobWakeMode.java | 11 + .../claw/agent/job/WorkspaceJobService.java | 243 ++++-- .../agent/model/enums/InboundTriggerType.java | 14 - .../agent/model/enums/RuntimeSourceKind.java | 17 + .../agent/model/enums/SystemEventPolicy.java | 13 + .../agent/model/envelope/InboundEnvelope.java | 12 +- .../agent/model/event/ConversationEvent.java | 3 + .../claw/agent/model/event/RunEvent.java | 3 + .../jimuqu/claw/agent/model/run/AgentRun.java | 5 + .../runtime/impl/AgentRuntimeService.java | 540 +++---------- .../agent/runtime/impl/HeartbeatService.java | 21 +- .../runtime/impl/IsolatedAgentRunService.java | 271 +++++++ .../impl/SolonAiConversationAgent.java | 89 +-- .../agent/runtime/impl/SystemEventRunner.java | 413 ++++++++++ .../runtime/support/AgentTurnRequest.java | 26 + .../support/ConversationExecutionRequest.java | 6 +- .../support/SystemAwareAgentSession.java | 5 + .../runtime/support/SystemEventRequest.java | 28 + .../claw/agent/store/RuntimeStoreService.java | 32 +- .../com/jimuqu/claw/agent/tool/JobTools.java | 70 +- .../jimuqu/claw/config/SolonClawConfig.java | 76 +- .../claw/config/props/AgentProperties.java | 6 + .../config/props/AgentTurnProperties.java | 18 + .../claw/config/props/JobsProperties.java | 22 + .../config/props/SystemEventsProperties.java | 19 + .../claw/agent/job/JobStoreServiceTest.java | 13 +- .../agent/job/WorkspaceJobServiceTest.java | 332 ++++++++ .../runtime/AgentRuntimeServiceTest.java | 709 ++---------------- .../agent/runtime/HeartbeatServiceTest.java | 95 +-- .../runtime/IsolatedAgentRunServiceTest.java | 144 ++++ .../agent/runtime/SystemEventRunnerTest.java | 210 ++++++ .../agent/store/RuntimeStoreServiceTest.java | 177 +---- 38 files changed, 2267 insertions(+), 1450 deletions(-) create mode 100644 src/main/java/com/jimuqu/claw/agent/job/AgentTurnSpec.java create mode 100644 src/main/java/com/jimuqu/claw/agent/job/JobDeliveryMode.java create mode 100644 src/main/java/com/jimuqu/claw/agent/job/JobPayloadKind.java create mode 100644 src/main/java/com/jimuqu/claw/agent/job/JobSessionTarget.java create mode 100644 src/main/java/com/jimuqu/claw/agent/job/JobWakeMode.java delete mode 100644 src/main/java/com/jimuqu/claw/agent/model/enums/InboundTriggerType.java create mode 100644 src/main/java/com/jimuqu/claw/agent/model/enums/RuntimeSourceKind.java create mode 100644 src/main/java/com/jimuqu/claw/agent/model/enums/SystemEventPolicy.java create mode 100644 src/main/java/com/jimuqu/claw/agent/runtime/impl/IsolatedAgentRunService.java create mode 100644 src/main/java/com/jimuqu/claw/agent/runtime/impl/SystemEventRunner.java create mode 100644 src/main/java/com/jimuqu/claw/agent/runtime/support/AgentTurnRequest.java create mode 100644 src/main/java/com/jimuqu/claw/agent/runtime/support/SystemEventRequest.java create mode 100644 src/main/java/com/jimuqu/claw/config/props/AgentTurnProperties.java create mode 100644 src/main/java/com/jimuqu/claw/config/props/JobsProperties.java create mode 100644 src/main/java/com/jimuqu/claw/config/props/SystemEventsProperties.java create mode 100644 src/test/java/com/jimuqu/claw/agent/job/WorkspaceJobServiceTest.java create mode 100644 src/test/java/com/jimuqu/claw/agent/runtime/IsolatedAgentRunServiceTest.java create mode 100644 src/test/java/com/jimuqu/claw/agent/runtime/SystemEventRunnerTest.java diff --git a/pom.xml b/pom.xml index d803e2a..b5c3713 100644 --- a/pom.xml +++ b/pom.xml @@ -7,7 +7,7 @@ org.noear solon-parent - 3.9.5 + 3.9.6-M3 diff --git a/src/main/java/com/jimuqu/claw/agent/job/AgentTurnSpec.java b/src/main/java/com/jimuqu/claw/agent/job/AgentTurnSpec.java new file mode 100644 index 0000000..c4df5b9 --- /dev/null +++ b/src/main/java/com/jimuqu/claw/agent/job/AgentTurnSpec.java @@ -0,0 +1,26 @@ +package com.jimuqu.claw.agent.job; + +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +/** + * 描述一次隔离 agent turn 的执行参数。 + */ +@Data +@NoArgsConstructor +public class AgentTurnSpec implements Serializable { + private static final long serialVersionUID = 1L; + + /** 任务描述或执行指令。 */ + private String message; + /** 可选模型覆盖。 */ + private String model; + /** 可选思考强度。 */ + private String thinking; + /** 可选超时时间,单位秒。 */ + private Integer timeoutSeconds; + /** 是否使用轻量上下文。 */ + private boolean lightContext; +} diff --git a/src/main/java/com/jimuqu/claw/agent/job/JobDefinition.java b/src/main/java/com/jimuqu/claw/agent/job/JobDefinition.java index 60fbdaa..938ff66 100644 --- a/src/main/java/com/jimuqu/claw/agent/job/JobDefinition.java +++ b/src/main/java/com/jimuqu/claw/agent/job/JobDefinition.java @@ -20,9 +20,14 @@ public class JobDefinition implements Serializable { private long initialDelay; private String zone; private boolean enabled = true; - private String prompt; - private String sessionKey; - private ReplyTarget replyTarget; + private JobPayloadKind payloadKind; + private JobSessionTarget sessionTarget; + private JobWakeMode wakeMode = JobWakeMode.NOW; + private JobDeliveryMode deliveryMode = JobDeliveryMode.NONE; + private String boundSessionKey; + private ReplyTarget boundReplyTarget; + private String systemEventText; + private AgentTurnSpec agentTurn = new AgentTurnSpec(); private long createdAt; private long updatedAt; } diff --git a/src/main/java/com/jimuqu/claw/agent/job/JobDeliveryMode.java b/src/main/java/com/jimuqu/claw/agent/job/JobDeliveryMode.java new file mode 100644 index 0000000..996a050 --- /dev/null +++ b/src/main/java/com/jimuqu/claw/agent/job/JobDeliveryMode.java @@ -0,0 +1,13 @@ +package com.jimuqu.claw.agent.job; + +/** + * 描述 agentTurn 定时任务的投递策略。 + */ +public enum JobDeliveryMode { + /** 不对外投递。 */ + NONE, + /** 投递到任务绑定的回复目标。 */ + BOUND_REPLY_TARGET, + /** 投递到当前最近一次外部可回复路由。 */ + LAST_ROUTE +} diff --git a/src/main/java/com/jimuqu/claw/agent/job/JobPayloadKind.java b/src/main/java/com/jimuqu/claw/agent/job/JobPayloadKind.java new file mode 100644 index 0000000..4ee6ded --- /dev/null +++ b/src/main/java/com/jimuqu/claw/agent/job/JobPayloadKind.java @@ -0,0 +1,11 @@ +package com.jimuqu.claw.agent.job; + +/** + * 描述定时任务的执行负载类型。 + */ +public enum JobPayloadKind { + /** 向主会话注入一条 system event。 */ + SYSTEM_EVENT, + /** 启动一次独立 agent turn。 */ + AGENT_TURN +} diff --git a/src/main/java/com/jimuqu/claw/agent/job/JobSessionTarget.java b/src/main/java/com/jimuqu/claw/agent/job/JobSessionTarget.java new file mode 100644 index 0000000..9bad97b --- /dev/null +++ b/src/main/java/com/jimuqu/claw/agent/job/JobSessionTarget.java @@ -0,0 +1,11 @@ +package com.jimuqu.claw.agent.job; + +/** + * 描述定时任务执行时使用主会话还是隔离会话。 + */ +public enum JobSessionTarget { + /** 主会话 system event。 */ + MAIN, + /** 隔离的独立 agent turn。 */ + ISOLATED +} diff --git a/src/main/java/com/jimuqu/claw/agent/job/JobWakeMode.java b/src/main/java/com/jimuqu/claw/agent/job/JobWakeMode.java new file mode 100644 index 0000000..d00f2ef --- /dev/null +++ b/src/main/java/com/jimuqu/claw/agent/job/JobWakeMode.java @@ -0,0 +1,11 @@ +package com.jimuqu.claw.agent.job; + +/** + * 描述 system event 定时任务的唤醒模式。 + */ +public enum JobWakeMode { + /** 触发后立即处理。 */ + NOW, + /** 触发后仅记录事件,等待下一次内部轮询。 */ + NEXT_TICK +} diff --git a/src/main/java/com/jimuqu/claw/agent/job/WorkspaceJobService.java b/src/main/java/com/jimuqu/claw/agent/job/WorkspaceJobService.java index 0d89334..f146944 100644 --- a/src/main/java/com/jimuqu/claw/agent/job/WorkspaceJobService.java +++ b/src/main/java/com/jimuqu/claw/agent/job/WorkspaceJobService.java @@ -1,9 +1,16 @@ package com.jimuqu.claw.agent.job; import cn.hutool.core.util.StrUtil; +import com.jimuqu.claw.agent.model.enums.RuntimeSourceKind; +import com.jimuqu.claw.agent.model.enums.SystemEventPolicy; import com.jimuqu.claw.agent.model.route.LatestReplyRoute; import com.jimuqu.claw.agent.model.route.ReplyTarget; +import com.jimuqu.claw.agent.runtime.impl.IsolatedAgentRunService; +import com.jimuqu.claw.agent.runtime.impl.SystemEventRunner; +import com.jimuqu.claw.agent.runtime.support.AgentTurnRequest; +import com.jimuqu.claw.agent.runtime.support.SystemEventRequest; import com.jimuqu.claw.agent.store.RuntimeStoreService; +import com.jimuqu.claw.config.SolonClawProperties; import org.noear.solon.scheduling.ScheduledAnno; import org.noear.solon.scheduling.ScheduledException; import org.noear.solon.scheduling.annotation.Scheduled; @@ -16,28 +23,27 @@ import java.util.List; * 管理工作区中的定时任务定义、恢复和运行。 */ public class WorkspaceJobService { - @FunctionalInterface - public interface JobDispatcher { - String dispatch(String sessionKey, ReplyTarget replyTarget, String prompt); - } - private final IJobManager jobManager; private final JobStoreService jobStoreService; private final RuntimeStoreService runtimeStoreService; - private JobDispatcher jobDispatcher; + private final SystemEventRunner systemEventRunner; + private final IsolatedAgentRunService isolatedAgentRunService; + private final SolonClawProperties properties; public WorkspaceJobService( IJobManager jobManager, JobStoreService jobStoreService, - RuntimeStoreService runtimeStoreService + RuntimeStoreService runtimeStoreService, + SystemEventRunner systemEventRunner, + IsolatedAgentRunService isolatedAgentRunService, + SolonClawProperties properties ) { this.jobManager = jobManager; this.jobStoreService = jobStoreService; this.runtimeStoreService = runtimeStoreService; - } - - public void setJobDispatcher(JobDispatcher jobDispatcher) { - this.jobDispatcher = jobDispatcher; + this.systemEventRunner = systemEventRunner; + this.isolatedAgentRunService = isolatedAgentRunService; + this.properties = properties; } public void restorePersistedJobs() { @@ -54,28 +60,94 @@ public class WorkspaceJobService { return jobStoreService.get(name); } - public JobDefinition addJob(String name, String mode, String scheduleValue, String prompt, long initialDelay, String zone) { - validate(name, mode, scheduleValue, prompt); - - LatestReplyRoute route = runtimeStoreService.getLatestExternalRoute(); - if (route == null || route.getReplyTarget() == null || StrUtil.isBlank(route.getSessionKey())) { - throw new IllegalStateException("当前没有可绑定的外部会话,无法创建定时任务。"); + public JobDefinition addSystemJob( + String name, + String mode, + String scheduleValue, + String systemEventText, + long initialDelay, + String zone, + JobWakeMode wakeMode + ) { + validateSchedule(name, mode, scheduleValue); + if (StrUtil.isBlank(systemEventText)) { + throw new IllegalArgumentException("systemEventText 不能为空"); } + LatestReplyRoute route = requireLatestExternalRoute(); + long now = System.currentTimeMillis(); + JobDefinition definition = new JobDefinition(); definition.setName(name.trim()); definition.setMode(mode.trim().toLowerCase()); definition.setScheduleValue(scheduleValue.trim()); - definition.setPrompt(prompt.trim()); definition.setInitialDelay(Math.max(0L, initialDelay)); definition.setZone(StrUtil.blankToDefault(zone, "").trim()); definition.setEnabled(true); - definition.setSessionKey(route.getSessionKey()); - definition.setReplyTarget(route.getReplyTarget()); + definition.setPayloadKind(JobPayloadKind.SYSTEM_EVENT); + definition.setSessionTarget(JobSessionTarget.MAIN); + definition.setWakeMode(wakeMode == null ? properties.getAgent().getJobs().getDefaultWakeMode() : wakeMode); + definition.setDeliveryMode(JobDeliveryMode.NONE); + definition.setBoundSessionKey(route.getSessionKey()); + definition.setBoundReplyTarget(route.getReplyTarget()); + definition.setSystemEventText(systemEventText.trim()); + definition.setAgentTurn(new AgentTurnSpec()); + definition.setCreatedAt(now); + definition.setUpdatedAt(now); + + validateDefinition(definition); + registerJob(definition); + jobStoreService.save(definition); + return definition; + } + + public JobDefinition addAgentJob( + String name, + String mode, + String scheduleValue, + AgentTurnSpec agentTurn, + long initialDelay, + String zone, + JobDeliveryMode deliveryMode + ) { + validateSchedule(name, mode, scheduleValue); + if (agentTurn == null || StrUtil.isBlank(agentTurn.getMessage())) { + throw new IllegalArgumentException("agentTurn.message 不能为空"); + } + + LatestReplyRoute route = requireLatestExternalRoute(); long now = System.currentTimeMillis(); + + AgentTurnSpec normalizedSpec = new AgentTurnSpec(); + normalizedSpec.setMessage(agentTurn.getMessage().trim()); + normalizedSpec.setModel(StrUtil.blankToDefault(StrUtil.trim(agentTurn.getModel()), null)); + normalizedSpec.setThinking(StrUtil.blankToDefault(StrUtil.trim(agentTurn.getThinking()), null)); + normalizedSpec.setTimeoutSeconds( + agentTurn.getTimeoutSeconds() == null + ? properties.getAgent().getAgentTurn().getDefaultTimeoutSeconds() + : agentTurn.getTimeoutSeconds() + ); + normalizedSpec.setLightContext(agentTurn.isLightContext()); + + JobDefinition definition = new JobDefinition(); + definition.setName(name.trim()); + definition.setMode(mode.trim().toLowerCase()); + definition.setScheduleValue(scheduleValue.trim()); + definition.setInitialDelay(Math.max(0L, initialDelay)); + definition.setZone(StrUtil.blankToDefault(zone, "").trim()); + definition.setEnabled(true); + definition.setPayloadKind(JobPayloadKind.AGENT_TURN); + definition.setSessionTarget(JobSessionTarget.ISOLATED); + definition.setWakeMode(JobWakeMode.NOW); + definition.setDeliveryMode(deliveryMode == null ? properties.getAgent().getJobs().getDefaultDeliveryMode() : deliveryMode); + definition.setBoundSessionKey(route.getSessionKey()); + definition.setBoundReplyTarget(route.getReplyTarget()); + definition.setSystemEventText(null); + definition.setAgentTurn(normalizedSpec); definition.setCreatedAt(now); definition.setUpdatedAt(now); + validateDefinition(definition); registerJob(definition); jobStoreService.save(definition); return definition; @@ -106,6 +178,7 @@ public class WorkspaceJobService { public JobDefinition startJob(String name) throws ScheduledException { JobDefinition definition = requireDefinition(name); + validateDefinition(definition); if (!jobManager.jobExists(definition.getName())) { registerJob(definition); } @@ -116,6 +189,14 @@ public class WorkspaceJobService { return definition; } + private LatestReplyRoute requireLatestExternalRoute() { + LatestReplyRoute route = runtimeStoreService.getLatestExternalRoute(); + if (route == null || route.getReplyTarget() == null || StrUtil.isBlank(route.getSessionKey())) { + throw new IllegalStateException("当前没有可绑定的外部会话,无法创建定时任务。"); + } + return route; + } + private JobDefinition requireDefinition(String name) { JobDefinition definition = jobStoreService.get(name); if (definition == null) { @@ -135,11 +216,7 @@ public class WorkspaceJobService { Scheduled scheduled = buildScheduled(definition); JobHolder holder = jobManager.jobAdd(definition.getName(), scheduled, ctx -> { - ReplyTarget replyTarget = definition.getReplyTarget(); - if (replyTarget == null || StrUtil.isBlank(definition.getSessionKey()) || jobDispatcher == null) { - return; - } - jobDispatcher.dispatch(definition.getSessionKey(), replyTarget, buildExecutionPrompt(definition)); + dispatch(definition); if (isOneShot(definition)) { removeJob(definition.getName()); } @@ -155,6 +232,30 @@ public class WorkspaceJobService { } } + private void dispatch(JobDefinition definition) { + if (definition.getPayloadKind() == JobPayloadKind.SYSTEM_EVENT) { + SystemEventRequest request = new SystemEventRequest(); + request.setSourceKind(RuntimeSourceKind.JOB_SYSTEM_EVENT); + request.setPolicy(SystemEventPolicy.USER_VISIBLE_OPTIONAL); + request.setSessionKey(definition.getBoundSessionKey()); + request.setReplyTarget(definition.getBoundReplyTarget()); + request.setContent(definition.getSystemEventText()); + request.setAllowNotifyUser(true); + request.setWakeImmediately(definition.getWakeMode() != JobWakeMode.NEXT_TICK); + systemEventRunner.submit(request); + return; + } + + AgentTurnRequest request = new AgentTurnRequest(); + request.setSourceKind(RuntimeSourceKind.JOB_AGENT_TURN); + request.setJobName(definition.getName()); + request.setBoundSessionKey(definition.getBoundSessionKey()); + request.setBoundReplyTarget(definition.getBoundReplyTarget()); + request.setDeliveryMode(definition.getDeliveryMode()); + request.setAgentTurn(copyAgentTurn(definition.getAgentTurn())); + isolatedAgentRunService.submit(request); + } + private Scheduled buildScheduled(JobDefinition definition) { ScheduledAnno scheduled = new ScheduledAnno() .name(definition.getName()) @@ -184,15 +285,7 @@ public class WorkspaceJobService { return scheduled; } - private long parseLong(String value, String fieldName) { - try { - return Long.parseLong(value); - } catch (NumberFormatException e) { - throw new IllegalArgumentException(fieldName + " 必须是毫秒数字: " + value, e); - } - } - - private void validate(String name, String mode, String scheduleValue, String prompt) { + private void validateSchedule(String name, String mode, String scheduleValue) { if (StrUtil.isBlank(name)) { throw new IllegalArgumentException("name 不能为空"); } @@ -202,9 +295,6 @@ public class WorkspaceJobService { if (StrUtil.isBlank(scheduleValue)) { throw new IllegalArgumentException("scheduleValue 不能为空"); } - if (StrUtil.isBlank(prompt)) { - throw new IllegalArgumentException("prompt 不能为空"); - } String normalized = mode.trim().toLowerCase(); if (!"fixed_rate".equals(normalized) @@ -215,34 +305,63 @@ public class WorkspaceJobService { } } + private void validateDefinition(JobDefinition definition) { + if (definition == null) { + throw new IllegalArgumentException("definition 不能为空"); + } + if (StrUtil.isBlank(definition.getName())) { + throw new IllegalArgumentException("name 不能为空"); + } + if (definition.getPayloadKind() == null) { + throw new IllegalArgumentException("payloadKind 不能为空"); + } + if (definition.getSessionTarget() == null) { + throw new IllegalArgumentException("sessionTarget 不能为空"); + } + if (definition.getPayloadKind() == JobPayloadKind.SYSTEM_EVENT) { + if (definition.getSessionTarget() != JobSessionTarget.MAIN) { + throw new IllegalArgumentException("SYSTEM_EVENT 任务必须使用 MAIN sessionTarget"); + } + if (StrUtil.isBlank(definition.getSystemEventText())) { + throw new IllegalArgumentException("SYSTEM_EVENT 任务必须提供 systemEventText"); + } + } else { + if (definition.getSessionTarget() != JobSessionTarget.ISOLATED) { + throw new IllegalArgumentException("AGENT_TURN 任务必须使用 ISOLATED sessionTarget"); + } + if (definition.getAgentTurn() == null || StrUtil.isBlank(definition.getAgentTurn().getMessage())) { + throw new IllegalArgumentException("AGENT_TURN 任务必须提供 agentTurn.message"); + } + } + if (definition.getDeliveryMode() == JobDeliveryMode.BOUND_REPLY_TARGET && definition.getBoundReplyTarget() == null) { + throw new IllegalArgumentException("BOUND_REPLY_TARGET 模式必须提供 boundReplyTarget"); + } + if (StrUtil.isBlank(definition.getBoundSessionKey())) { + throw new IllegalArgumentException("boundSessionKey 不能为空"); + } + } + + private long parseLong(String value, String fieldName) { + try { + return Long.parseLong(value); + } catch (NumberFormatException e) { + throw new IllegalArgumentException(fieldName + " 必须是毫秒数字: " + value, e); + } + } + private boolean isOneShot(JobDefinition definition) { return "once_delay".equals(definition.getMode()); } - /** - * 将定时任务执行包装成明确的内部事件,避免模型把触发内容误解为新的建任务请求。 - * - * @param definition 定时任务定义 - * @return 运行时提交给 Agent 的内部提示 - */ - private String buildExecutionPrompt(JobDefinition definition) { - StringBuilder builder = new StringBuilder(); - builder.append("[内部定时任务触发]").append('\n'); - builder.append("任务名称: ").append(StrUtil.blankToDefault(definition.getName(), "(未命名)")).append('\n'); - builder.append("调度模式: ").append(StrUtil.blankToDefault(definition.getMode(), "(未知)")).append('\n'); - if (StrUtil.isNotBlank(definition.getScheduleValue())) { - builder.append("调度值: ").append(definition.getScheduleValue()).append('\n'); - } - builder.append("提醒内容:").append('\n'); - builder.append(StrUtil.blankToDefault(definition.getPrompt(), "(空)")).append('\n'); - builder.append('\n'); - builder.append("处理规则:").append('\n'); - builder.append("- 这是一个已经存在的定时提醒正在触发,不是用户要求新建、修改、删除或查询定时任务。").append('\n'); - builder.append("- 请把这条提醒自然、友好地告知用户。优先调用 notify_user 发送提醒;发送后返回 NO_REPLY。").append('\n'); - builder.append("- 如果你直接产出了面向用户的提醒文案,运行时会代为发送一次;不要再重复解释内部触发过程。").append('\n'); - builder.append("- 不要调用 add_job、remove_job、start_job、stop_job、list_jobs、get_job。").append('\n'); - builder.append("- 如果本次无需对外提醒,也直接返回 NO_REPLY。"); - return builder.toString(); + private AgentTurnSpec copyAgentTurn(AgentTurnSpec source) { + AgentTurnSpec copy = new AgentTurnSpec(); + if (source != null) { + copy.setMessage(source.getMessage()); + copy.setModel(source.getModel()); + copy.setThinking(source.getThinking()); + copy.setTimeoutSeconds(source.getTimeoutSeconds()); + copy.setLightContext(source.isLightContext()); + } + return copy; } } - diff --git a/src/main/java/com/jimuqu/claw/agent/model/enums/InboundTriggerType.java b/src/main/java/com/jimuqu/claw/agent/model/enums/InboundTriggerType.java deleted file mode 100644 index b06a420..0000000 --- a/src/main/java/com/jimuqu/claw/agent/model/enums/InboundTriggerType.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.jimuqu.claw.agent.model.enums; - -/** - * 描述一次入站触发在运行时中的语义类型。 - */ -public enum InboundTriggerType { - /** 用户主动发起的普通消息。 */ - USER, - /** 需要进入会话轨迹、但不应被视作用户发言的系统触发。 */ - SYSTEM_VISIBLE, - /** 仅用于内部检查的静默系统触发。 */ - SYSTEM_SILENT -} - diff --git a/src/main/java/com/jimuqu/claw/agent/model/enums/RuntimeSourceKind.java b/src/main/java/com/jimuqu/claw/agent/model/enums/RuntimeSourceKind.java new file mode 100644 index 0000000..3821635 --- /dev/null +++ b/src/main/java/com/jimuqu/claw/agent/model/enums/RuntimeSourceKind.java @@ -0,0 +1,17 @@ +package com.jimuqu.claw.agent.model.enums; + +/** + * 描述一次运行请求的来源类型。 + */ +public enum RuntimeSourceKind { + /** 用户主动发送的消息,包括 debug-web。 */ + USER_MESSAGE, + /** 定时任务的 systemEvent 触发。 */ + JOB_SYSTEM_EVENT, + /** 定时任务的 agentTurn 触发。 */ + JOB_AGENT_TURN, + /** 心跳内部检查触发。 */ + HEARTBEAT_EVENT, + /** 子任务完成后的父会话 continuation。 */ + CHILD_CONTINUATION +} diff --git a/src/main/java/com/jimuqu/claw/agent/model/enums/SystemEventPolicy.java b/src/main/java/com/jimuqu/claw/agent/model/enums/SystemEventPolicy.java new file mode 100644 index 0000000..815c448 --- /dev/null +++ b/src/main/java/com/jimuqu/claw/agent/model/enums/SystemEventPolicy.java @@ -0,0 +1,13 @@ +package com.jimuqu.claw.agent.model.enums; + +/** + * 描述系统事件的执行与外发策略。 + */ +public enum SystemEventPolicy { + /** 仅允许内部处理,不允许最终普通回复外发。 */ + INTERNAL_ONLY, + /** 允许用户可见动作;可使用 notify_user,必要时可兜底外发一次提醒文案。 */ + USER_VISIBLE_OPTIONAL, + /** 仅允许 continuation 聚合型回复;必须通过 FINAL_REPLY_ONCE: 明确声明。 */ + AGGREGATE_ONLY +} diff --git a/src/main/java/com/jimuqu/claw/agent/model/envelope/InboundEnvelope.java b/src/main/java/com/jimuqu/claw/agent/model/envelope/InboundEnvelope.java index 000bf24..45f1177 100644 --- a/src/main/java/com/jimuqu/claw/agent/model/envelope/InboundEnvelope.java +++ b/src/main/java/com/jimuqu/claw/agent/model/envelope/InboundEnvelope.java @@ -2,7 +2,7 @@ package com.jimuqu.claw.agent.model.envelope; import com.jimuqu.claw.agent.model.enums.ChannelType; import com.jimuqu.claw.agent.model.enums.ConversationType; -import com.jimuqu.claw.agent.model.enums.InboundTriggerType; +import com.jimuqu.claw.agent.model.enums.RuntimeSourceKind; import com.jimuqu.claw.agent.model.route.ReplyTarget; import lombok.Data; import lombok.NoArgsConstructor; @@ -43,14 +43,8 @@ public class InboundEnvelope implements Serializable { private String sessionKey; /** 会话事件版本号。 */ private long sessionVersion; - /** 当前入站触发类型。 */ - private InboundTriggerType triggerType = InboundTriggerType.USER; + /** 当前入站来源类型。 */ + private RuntimeSourceKind sourceKind = RuntimeSourceKind.USER_MESSAGE; /** 当前运行关联到的历史锚点版本。 */ private long historyAnchorVersion; - /** 是否允许将最终回复回发到外部渠道。 */ - private boolean externalReplyEnabled = true; - /** 是否将当前入站消息写入会话历史。 */ - private boolean persistInboundConversationEvent = true; - /** 是否将本次运行产生的助手回复写入会话历史。 */ - private boolean persistAssistantConversationEvent = true; } diff --git a/src/main/java/com/jimuqu/claw/agent/model/event/ConversationEvent.java b/src/main/java/com/jimuqu/claw/agent/model/event/ConversationEvent.java index b3dfd1e..2590ae3 100644 --- a/src/main/java/com/jimuqu/claw/agent/model/event/ConversationEvent.java +++ b/src/main/java/com/jimuqu/claw/agent/model/event/ConversationEvent.java @@ -1,5 +1,6 @@ package com.jimuqu.claw.agent.model.event; +import com.jimuqu.claw.agent.model.enums.RuntimeSourceKind; import lombok.Data; import lombok.NoArgsConstructor; @@ -21,6 +22,8 @@ public class ConversationEvent implements Serializable { private String eventType; /** 关联运行任务标识。 */ private String runId; + /** 来源类型。 */ + private RuntimeSourceKind sourceKind; /** 来源消息标识。 */ private String sourceMessageId; /** 来源用户消息对应的版本号。 */ diff --git a/src/main/java/com/jimuqu/claw/agent/model/event/RunEvent.java b/src/main/java/com/jimuqu/claw/agent/model/event/RunEvent.java index e9da700..9abdc9a 100644 --- a/src/main/java/com/jimuqu/claw/agent/model/event/RunEvent.java +++ b/src/main/java/com/jimuqu/claw/agent/model/event/RunEvent.java @@ -1,5 +1,6 @@ package com.jimuqu.claw.agent.model.event; +import com.jimuqu.claw.agent.model.enums.RuntimeSourceKind; import lombok.Data; import lombok.NoArgsConstructor; @@ -17,6 +18,8 @@ public class RunEvent implements Serializable { private long seq; /** 所属运行任务标识。 */ private String runId; + /** 来源类型。 */ + private RuntimeSourceKind sourceKind; /** 事件类型。 */ private String eventType; /** 事件消息文本。 */ diff --git a/src/main/java/com/jimuqu/claw/agent/model/run/AgentRun.java b/src/main/java/com/jimuqu/claw/agent/model/run/AgentRun.java index f91c945..1c83fe5 100644 --- a/src/main/java/com/jimuqu/claw/agent/model/run/AgentRun.java +++ b/src/main/java/com/jimuqu/claw/agent/model/run/AgentRun.java @@ -1,6 +1,7 @@ package com.jimuqu.claw.agent.model.run; import com.jimuqu.claw.agent.model.enums.RunStatus; +import com.jimuqu.claw.agent.model.enums.RuntimeSourceKind; import com.jimuqu.claw.agent.model.route.ReplyTarget; import lombok.Data; import lombok.NoArgsConstructor; @@ -21,6 +22,8 @@ public class AgentRun implements Serializable { private String sessionKey; /** 来源消息标识。 */ private String sourceMessageId; + /** 运行来源类型。 */ + private RuntimeSourceKind sourceKind = RuntimeSourceKind.USER_MESSAGE; /** 来源用户消息版本号。 */ private long sourceUserVersion; /** 父运行任务标识;为空表示根运行。 */ @@ -33,6 +36,8 @@ public class AgentRun implements Serializable { private String taskDescription; /** 当前运行所属的子任务批次键。 */ private String batchKey; + /** 当前运行关联的业务运行标识,例如 continuation 对应的父运行。 */ + private String relatedRunId; /** 原路回复目标。 */ private ReplyTarget replyTarget; /** 当前运行状态。 */ diff --git a/src/main/java/com/jimuqu/claw/agent/runtime/impl/AgentRuntimeService.java b/src/main/java/com/jimuqu/claw/agent/runtime/impl/AgentRuntimeService.java index 2a9f0a4..cf5ae66 100644 --- a/src/main/java/com/jimuqu/claw/agent/runtime/impl/AgentRuntimeService.java +++ b/src/main/java/com/jimuqu/claw/agent/runtime/impl/AgentRuntimeService.java @@ -4,15 +4,15 @@ 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.run.AgentRun; -import com.jimuqu.claw.agent.model.enums.ChannelType; -import com.jimuqu.claw.agent.model.enums.ConversationType; import com.jimuqu.claw.agent.model.envelope.InboundEnvelope; -import com.jimuqu.claw.agent.model.enums.InboundTriggerType; import com.jimuqu.claw.agent.model.envelope.OutboundEnvelope; -import com.jimuqu.claw.agent.model.route.ReplyTarget; -import com.jimuqu.claw.agent.model.event.RunEvent; +import com.jimuqu.claw.agent.model.enums.ChannelType; +import com.jimuqu.claw.agent.model.enums.ConversationType; +import com.jimuqu.claw.agent.model.enums.RuntimeSourceKind; import com.jimuqu.claw.agent.model.enums.RunStatus; +import com.jimuqu.claw.agent.model.event.RunEvent; +import com.jimuqu.claw.agent.model.route.ReplyTarget; +import com.jimuqu.claw.agent.model.run.AgentRun; import com.jimuqu.claw.agent.runtime.api.ConversationAgent; import com.jimuqu.claw.agent.runtime.api.NotificationSupport; import com.jimuqu.claw.agent.runtime.api.RunQuerySupport; @@ -21,6 +21,7 @@ import com.jimuqu.claw.agent.runtime.support.ConversationExecutionRequest; import com.jimuqu.claw.agent.runtime.support.NotificationResult; import com.jimuqu.claw.agent.runtime.support.ParentRunChildrenSummary; import com.jimuqu.claw.agent.runtime.support.SpawnTaskResult; +import com.jimuqu.claw.agent.runtime.support.SystemEventRequest; import com.jimuqu.claw.agent.store.RuntimeStoreService; import com.jimuqu.claw.config.SolonClawProperties; import org.slf4j.Logger; @@ -29,56 +30,37 @@ import org.slf4j.LoggerFactory; import java.util.List; /** - * 协调消息入站、任务调度、状态落盘和出站发送的核心运行时服务。 + * 处理用户消息主链,并为子任务与查询提供运行时能力。 */ public class AgentRuntimeService { - /** 父会话可用来抑制中间回复的保留字。 */ public static final String NO_REPLY = "NO_REPLY"; - /** 父会话可用来声明“仅发送一次最终汇总”的保留前缀。 */ public static final String FINAL_REPLY_ONCE_PREFIX = "FINAL_REPLY_ONCE:"; - /** 日志记录器。 */ + private static final Logger log = LoggerFactory.getLogger(AgentRuntimeService.class); - /** 会话执行 Agent。 */ + private final ConversationAgent conversationAgent; - /** 运行时存储服务。 */ private final RuntimeStoreService runtimeStoreService; - /** 会话调度器。 */ private final ConversationScheduler conversationScheduler; - /** 渠道注册表。 */ private final ChannelRegistry channelRegistry; - /** 项目配置。 */ + private final SystemEventRunner systemEventRunner; private final SolonClawProperties properties; - /** - * 创建 Agent 运行时服务。 - * - * @param conversationAgent 会话执行 Agent - * @param runtimeStoreService 运行时存储服务 - * @param conversationScheduler 会话调度器 - * @param channelRegistry 渠道注册表 - * @param properties 项目配置 - */ public AgentRuntimeService( ConversationAgent conversationAgent, RuntimeStoreService runtimeStoreService, ConversationScheduler conversationScheduler, ChannelRegistry channelRegistry, + SystemEventRunner systemEventRunner, SolonClawProperties properties ) { this.conversationAgent = conversationAgent; this.runtimeStoreService = runtimeStoreService; this.conversationScheduler = conversationScheduler; this.channelRegistry = channelRegistry; + this.systemEventRunner = systemEventRunner; this.properties = properties; } - /** - * 向调试页渠道提交一条消息。 - * - * @param sessionId 调试会话标识 - * @param message 文本消息 - * @return 运行任务标识 - */ public String submitDebugMessage(String sessionId, String message) { InboundEnvelope inboundEnvelope = new InboundEnvelope(); inboundEnvelope.setMessageId("debug-" + IdUtil.fastSimpleUUID()); @@ -91,150 +73,21 @@ 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); + inboundEnvelope.setSourceKind(RuntimeSourceKind.USER_MESSAGE); return submitInbound(inboundEnvelope); } - /** - * 向指定外部路由提交一条系统消息。 - * - * @param sessionKey 会话键 - * @param replyTarget 回复目标 - * @param content 文本内容 - * @return 运行任务标识 - */ - public String submitSystemMessage(String sessionKey, ReplyTarget replyTarget, String content) { - 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"); - } - - /** - * 向指定外部路由提交一条仅用于内部处理的静默系统消息。 - * - * @param sessionKey 会话键 - * @param replyTarget 回复目标 - * @param content 文本内容 - * @return 运行任务标识 - */ - public String submitSilentSystemMessage(String sessionKey, ReplyTarget replyTarget, String content) { - return submitSilentSystemMessage(sessionKey, replyTarget, content, "system"); - } - - /** - * 向指定外部路由提交一条带自定义发送者的系统消息。 - * - * @param sessionKey 会话键 - * @param replyTarget 回复目标 - * @param content 文本内容 - * @param senderId 发送者标识 - * @return 运行任务标识 - */ - public String submitSystemMessage(String sessionKey, ReplyTarget replyTarget, String content, String senderId) { - 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 - ); - } - - /** - * 向指定外部路由提交一条仅用于内部处理的静默系统消息。 - * - * @param sessionKey 会话键 - * @param replyTarget 回复目标 - * @param content 文本内容 - * @param senderId 发送者标识 - * @return 运行任务标识 - */ - public String submitSilentSystemMessage(String sessionKey, ReplyTarget replyTarget, String content, String senderId) { - return submitSystemMessage( - sessionKey, - replyTarget, - content, - senderId, - InboundTriggerType.SYSTEM_SILENT, - false, - false, - false - ); - } - - /** - * 统一构造系统入站消息。 - * - * @param sessionKey 会话键 - * @param replyTarget 回复目标 - * @param content 文本内容 - * @param senderId 发送者标识 - * @param externalReplyEnabled 是否允许外部回发 - * @param persistInboundConversationEvent 是否写入入站会话事件 - * @param persistAssistantConversationEvent 是否写入助手回复会话事件 - * @return 运行任务标识 - */ - private String submitSystemMessage( - String sessionKey, - ReplyTarget replyTarget, - String content, - String senderId, - InboundTriggerType triggerType, - boolean externalReplyEnabled, - boolean persistInboundConversationEvent, - boolean persistAssistantConversationEvent - ) { - InboundEnvelope inboundEnvelope = new InboundEnvelope(); - inboundEnvelope.setMessageId("system-" + IdUtil.fastSimpleUUID()); - inboundEnvelope.setChannelType(ChannelType.SYSTEM); - inboundEnvelope.setChannelInstanceId("system"); - inboundEnvelope.setSenderId(StrUtil.blankToDefault(senderId, "system")); - inboundEnvelope.setConversationId(replyTarget == null ? sessionKey : replyTarget.getConversationId()); - inboundEnvelope.setConversationType(replyTarget == null ? ConversationType.PRIVATE : replyTarget.getConversationType()); - inboundEnvelope.setContent(content); - inboundEnvelope.setReceivedAt(System.currentTimeMillis()); - inboundEnvelope.setSessionKey(sessionKey); - inboundEnvelope.setReplyTarget(replyTarget); - inboundEnvelope.setTriggerType(triggerType); - inboundEnvelope.setExternalReplyEnabled(externalReplyEnabled); - inboundEnvelope.setPersistInboundConversationEvent(persistInboundConversationEvent); - inboundEnvelope.setPersistAssistantConversationEvent(persistAssistantConversationEvent); - return submitInbound(inboundEnvelope); - } - - /** - * 提交一条标准化后的入站消息。 - * - * @param inboundEnvelope 入站消息 - * @return 新建运行任务标识;若命中去重则返回 null - */ public String submitInbound(InboundEnvelope inboundEnvelope) { + if (inboundEnvelope == null) { + return null; + } + if (inboundEnvelope.getSourceKind() == null) { + inboundEnvelope.setSourceKind(RuntimeSourceKind.USER_MESSAGE); + } + if (inboundEnvelope.getSourceKind() != RuntimeSourceKind.USER_MESSAGE) { + throw new IllegalArgumentException("AgentRuntimeService 只接收 USER_MESSAGE"); + } + if (!runtimeStoreService.registerInbound(inboundEnvelope.getChannelType(), inboundEnvelope.getMessageId())) { log.info( "Ignore duplicated inbound message. channelType={}, messageId={}", @@ -248,16 +101,14 @@ public class AgentRuntimeService { long latestConversationVersion = runtimeStoreService.getLatestConversationVersion(inboundEnvelope.getSessionKey()); long nextConversationVersion = latestConversationVersion + 1L; - inboundEnvelope.setHistoryAnchorVersion(resolveHistoryAnchorVersion(inboundEnvelope, nextConversationVersion)); - long version = inboundEnvelope.isPersistInboundConversationEvent() - ? runtimeStoreService.appendInboundConversationEvent(inboundEnvelope) - : nextConversationVersion; + inboundEnvelope.setHistoryAnchorVersion(nextConversationVersion); + long version = runtimeStoreService.appendInboundConversationEvent(inboundEnvelope); inboundEnvelope.setSessionVersion(version); - if (inboundEnvelope.getChannelType() != ChannelType.SYSTEM) { + if (inboundEnvelope.getReplyTarget() != null && !inboundEnvelope.getReplyTarget().isDebugWeb()) { runtimeStoreService.rememberReplyTarget(inboundEnvelope.getSessionKey(), inboundEnvelope.getReplyTarget()); } log.info( - "Accepted inbound message. channelType={}, sessionKey={}, messageId={}, sessionVersion={}", + "Accepted inbound user message. channelType={}, sessionKey={}, messageId={}, sessionVersion={}", inboundEnvelope.getChannelType(), inboundEnvelope.getSessionKey(), inboundEnvelope.getMessageId(), @@ -268,17 +119,16 @@ public class AgentRuntimeService { run.setRunId(runtimeStoreService.newRunId()); run.setSessionKey(inboundEnvelope.getSessionKey()); run.setSourceMessageId(inboundEnvelope.getMessageId()); + run.setSourceKind(RuntimeSourceKind.USER_MESSAGE); run.setSourceUserVersion(inboundEnvelope.getHistoryAnchorVersion()); run.setReplyTarget(inboundEnvelope.getReplyTarget()); run.setStatus(RunStatus.QUEUED); run.setCreatedAt(System.currentTimeMillis()); runtimeStoreService.saveRun(run); runtimeStoreService.appendRunEvent(run.getRunId(), "status", "queued"); - log.info("Created run {} for session {}", run.getRunId(), run.getSessionKey()); if (properties.getAgent().getScheduler().isAckWhenBusy() && state.activeCount() > 0 - && inboundEnvelope.isExternalReplyEnabled() && inboundEnvelope.getReplyTarget() != null && !inboundEnvelope.getReplyTarget().isDebugWeb()) { OutboundEnvelope ack = new OutboundEnvelope(); @@ -292,45 +142,18 @@ public class AgentRuntimeService { return run.getRunId(); } - /** - * 查询单个运行任务。 - * - * @param runId 运行任务标识 - * @return 运行任务 - */ public AgentRun getRun(String runId) { return runtimeStoreService.getRun(runId); } - /** - * 查询某个运行任务的增量事件。 - * - * @param runId 运行任务标识 - * @param afterSeq 起始序号 - * @return 运行事件列表 - */ public List getRunEvents(String runId, long afterSeq) { return runtimeStoreService.getRunEvents(runId, afterSeq); } - /** - * 查询某个父运行下的子任务列表。 - * - * @param parentRunId 父运行标识 - * @param batchKey 批次键;为空时返回全部 - * @return 子任务列表 - */ public List listChildRuns(String parentRunId, String batchKey) { return runtimeStoreService.listChildRunsByParentRun(parentRunId, StrUtil.blankToDefault(StrUtil.trim(batchKey), null)); } - /** - * 聚合某个父运行下的子任务状态。 - * - * @param parentRunId 父运行标识 - * @param batchKey 批次键;为空时聚合全部 - * @return 聚合结果;若不存在则返回 null - */ public ParentRunChildrenSummary getChildSummary(String parentRunId, String batchKey) { if (StrUtil.isBlank(parentRunId)) { return null; @@ -342,12 +165,6 @@ public class AgentRuntimeService { return summary.getTotalChildren() == 0 ? null : summary; } - /** - * 执行一次真正的运行任务处理。 - * - * @param inboundEnvelope 入站消息 - * @param runId 运行任务标识 - */ private void processRun(InboundEnvelope inboundEnvelope, String runId) { AgentRun run = runtimeStoreService.getRun(runId); if (run == null) { @@ -364,10 +181,13 @@ public class AgentRuntimeService { ConversationExecutionRequest request = new ConversationExecutionRequest(); request.setSessionKey(inboundEnvelope.getSessionKey()); request.setCurrentMessage(inboundEnvelope.getContent()); - request.setCurrentMessageTriggerType(inboundEnvelope.getTriggerType()); + request.setCurrentSourceKind(run.getSourceKind()); request.setChildRun(StrUtil.isNotBlank(run.getParentRunId())); request.setParentRunId(run.getParentRunId()); - request.setHistory(runtimeStoreService.loadConversationHistoryBefore(inboundEnvelope.getSessionKey(), inboundEnvelope.getSessionVersion())); + request.setHistory(runtimeStoreService.loadConversationHistoryBefore( + inboundEnvelope.getSessionKey(), + inboundEnvelope.getSessionVersion() + )); request.setSpawnTaskSupport(buildSpawnTaskSupport(runId, run, inboundEnvelope)); request.setRunQuerySupport(buildRunQuerySupport(inboundEnvelope.getSessionKey())); request.setNotificationSupport(buildNotificationSupport(inboundEnvelope.getSessionKey(), runId)); @@ -378,22 +198,22 @@ public class AgentRuntimeService { runtimeStoreService.appendRunEvent(runId, "progress", progress); dispatchProgressOutbound(runId, inboundEnvelope, progress); }); - AgentRun latestRun = runtimeStoreService.getRun(runId); - if (latestRun != null) { - run = latestRun; - } - if (StrUtil.isBlank(response)) { response = latestProgress[0]; } - String childCompletionParentRunId = resolveChildCompletionParentRunId(inboundEnvelope); - boolean finalReplyOnce = isFinalReplyOnce(response); + + run = runtimeStoreService.getRun(runId); + if (run == null) { + return; + } + String visibleResponse = normalizeVisibleResponse(response); + if (StrUtil.isBlank(visibleResponse)) { + handleEmptyUserResponse(run, inboundEnvelope, runId); + return; + } + boolean suppressReply = isNoReply(response); run.setFinalResponse(visibleResponse); - boolean suppressReply = isNoReply(response) - || (finalReplyOnce - && StrUtil.isNotBlank(childCompletionParentRunId) - && runtimeStoreService.hasRunEventType(childCompletionParentRunId, "children_aggregated")); if (run.getStatus() == RunStatus.WAITING_CHILDREN) { run.setFinishedAt(System.currentTimeMillis()); @@ -404,12 +224,13 @@ public class AgentRuntimeService { return; } - if (!suppressReply && inboundEnvelope.isPersistAssistantConversationEvent()) { + if (!suppressReply) { runtimeStoreService.appendAssistantConversationEvent( inboundEnvelope.getSessionKey(), runId, inboundEnvelope.getMessageId(), - resolveAssistantSourceUserVersion(inboundEnvelope), + inboundEnvelope.getHistoryAnchorVersion(), + run.getSourceKind(), visibleResponse ); } @@ -420,15 +241,10 @@ public class AgentRuntimeService { runtimeStoreService.saveRun(run); runtimeStoreService.appendRunEvent(runId, "reply", visibleResponse); runtimeStoreService.appendRunEvent(runId, "status", "succeeded"); - if (!suppressReply && finalReplyOnce && StrUtil.isNotBlank(childCompletionParentRunId)) { - runtimeStoreService.appendRunEvent(childCompletionParentRunId, "children_aggregated", "aggregateRunId=" + runId); - } - boolean silentReminderDelivered = maybeDeliverSilentReminder(runId, inboundEnvelope, visibleResponse); log.info("Run {} succeeded for session {}", runId, inboundEnvelope.getSessionKey()); handleChildRunCompletion(run); if (!suppressReply - && inboundEnvelope.isExternalReplyEnabled() && inboundEnvelope.getReplyTarget() != null && !inboundEnvelope.getReplyTarget().isDebugWeb()) { OutboundEnvelope outboundEnvelope = new OutboundEnvelope(); @@ -443,14 +259,6 @@ public class AgentRuntimeService { inboundEnvelope.getReplyTarget().getConversationType(), inboundEnvelope.getReplyTarget().getConversationId() ); - } else if (silentReminderDelivered) { - log.info( - "Run {} reminder dispatched via silent fallback. channelType={}, conversationType={}, conversationId={}", - runId, - inboundEnvelope.getReplyTarget().getChannelType(), - inboundEnvelope.getReplyTarget().getConversationType(), - inboundEnvelope.getReplyTarget().getConversationId() - ); } } catch (Throwable throwable) { run.setStatus(RunStatus.FAILED); @@ -462,8 +270,7 @@ public class AgentRuntimeService { log.warn("Run {} failed for session {}: {}", runId, inboundEnvelope.getSessionKey(), throwable.getMessage(), throwable); handleChildRunCompletion(run); - if (inboundEnvelope.isExternalReplyEnabled() - && inboundEnvelope.getReplyTarget() != null + if (inboundEnvelope.getReplyTarget() != null && !inboundEnvelope.getReplyTarget().isDebugWeb()) { OutboundEnvelope outboundEnvelope = new OutboundEnvelope(); outboundEnvelope.setRunId(runId); @@ -474,14 +281,6 @@ public class AgentRuntimeService { } } - /** - * 从父运行中派生一个独立子任务运行。 - * - * @param parentRunId 父运行任务标识 - * @param parentInbound 父运行入站消息 - * @param taskDescription 子任务描述 - * @return 子任务创建结果 - */ private SpawnTaskResult spawnTask(String parentRunId, InboundEnvelope parentInbound, String taskDescription, String batchKey) { if (StrUtil.isBlank(taskDescription)) { throw new IllegalArgumentException("taskDescription 不能为空"); @@ -499,7 +298,7 @@ public class AgentRuntimeService { InboundEnvelope childInbound = new InboundEnvelope(); childInbound.setMessageId(childMessageId); childInbound.setChannelType(ChannelType.SYSTEM); - childInbound.setChannelInstanceId("system"); + childInbound.setChannelInstanceId("subtask"); childInbound.setSenderId("parent-run:" + parentRunId); childInbound.setConversationId(childSessionKey); childInbound.setConversationType(ConversationType.PRIVATE); @@ -507,17 +306,18 @@ public class AgentRuntimeService { childInbound.setReceivedAt(now); childInbound.setSessionKey(childSessionKey); childInbound.setReplyTarget(null); - childInbound.setTriggerType(InboundTriggerType.SYSTEM_VISIBLE); + childInbound.setSourceKind(RuntimeSourceKind.USER_MESSAGE); long version = runtimeStoreService.appendInboundConversationEvent(childInbound); childInbound.setSessionVersion(version); - childInbound.setHistoryAnchorVersion(resolveHistoryAnchorVersion(childInbound, version)); + childInbound.setHistoryAnchorVersion(version); AgentRun childRun = new AgentRun(); childRun.setRunId(runtimeStoreService.newRunId()); childRun.setSessionKey(childSessionKey); childRun.setSourceMessageId(childMessageId); - childRun.setSourceUserVersion(childInbound.getHistoryAnchorVersion()); + childRun.setSourceKind(RuntimeSourceKind.USER_MESSAGE); + childRun.setSourceUserVersion(version); childRun.setStatus(RunStatus.QUEUED); childRun.setCreatedAt(now); childRun.setParentRunId(parentRunId); @@ -544,13 +344,6 @@ public class AgentRuntimeService { parentRun.getSourceUserVersion(), childRun ); - log.info( - "Spawned child run {} for parent run {}. parentSession={}, childSession={}", - childRun.getRunId(), - parentRunId, - parentInbound.getSessionKey(), - childSessionKey - ); conversationScheduler.submit(childSessionKey, () -> processRun(childInbound, childRun.getRunId())); @@ -562,11 +355,6 @@ public class AgentRuntimeService { return result; } - /** - * 在子运行结束后,向父会话回写内部事件并触发 continuation run。 - * - * @param run 已完成的运行任务 - */ private void handleChildRunCompletion(AgentRun run) { if (run == null || StrUtil.isBlank(run.getParentRunId()) || StrUtil.isBlank(run.getParentSessionKey())) { return; @@ -580,69 +368,35 @@ public class AgentRuntimeService { : runtimeStoreService.summarizeChildRuns(run.getParentRunId(), run.getBatchKey()); appendParentChildCompletionEvents(run, overallSummary, batchSummary); - String internalMessage = buildChildCompletionMessage(run, overallSummary, batchSummary); runtimeStoreService.appendChildRunCompletedEvent(run.getParentSessionKey(), run.getParentRunId(), sourceUserVersion, run); - String continuationRunId = submitSystemMessage( - run.getParentSessionKey(), - run.getParentReplyTarget(), - internalMessage, - "child-complete:" + run.getParentRunId() - ); + SystemEventRequest request = new SystemEventRequest(); + request.setSourceKind(RuntimeSourceKind.CHILD_CONTINUATION); + request.setSessionKey(run.getParentSessionKey()); + request.setReplyTarget(run.getParentReplyTarget()); + request.setPolicy(com.jimuqu.claw.agent.model.enums.SystemEventPolicy.AGGREGATE_ONLY); + request.setContent(buildChildCompletionMessage(run, overallSummary, batchSummary)); + request.setSourceUserVersion(sourceUserVersion); + request.setRelatedRunId(run.getParentRunId()); + request.setAllowNotifyUser(false); + String continuationRunId = systemEventRunner.submit(request); runtimeStoreService.appendRunEvent( run.getParentRunId(), - "child_continuation_submitted", + "child_continuation_triggered", "childRunId=" + run.getRunId() + ", continuationRunId=" + continuationRunId + ", pendingChildren=" + (overallSummary == null ? 0 : overallSummary.getPendingChildren()) ); } - /** - * 计算当前入站消息对应的历史锚点版本。 - * - * @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(); - } - - /** - * 构造子运行完成后回流父会话的内部消息。 - * - * @param run 子运行 - * @return 内部消息文本 - */ private String buildChildCompletionMessage( AgentRun run, ParentRunChildrenSummary overallSummary, ParentRunChildrenSummary batchSummary ) { StringBuilder builder = new StringBuilder(); - builder.append("[内部事件] 子任务已完成").append('\n'); + builder.append("[子任务 continuation 事件]").append('\n'); builder.append("父运行ID: ").append(run.getParentRunId()).append('\n'); builder.append("子运行ID: ").append(run.getRunId()).append('\n'); - builder.append("子会话: ").append(run.getSessionKey()).append('\n'); builder.append("状态: ").append(run.getStatus()).append('\n'); if (StrUtil.isNotBlank(run.getTaskDescription())) { builder.append("任务: ").append(run.getTaskDescription()).append('\n'); @@ -669,25 +423,9 @@ public class AgentRuntimeService { } else { builder.append("错误:\n").append(StrUtil.blankToDefault(run.getErrorMessage(), "(未知错误)")); } - builder.append("\n\n请基于已有上下文继续处理。"); - if (overallSummary != null && overallSummary.getPendingChildren() > 0) { - builder.append("\n- 仍有子任务未完成时,优先返回 NO_REPLY,避免过早对外回复。"); - builder.append("\n- 若需要了解进度,可用 get_child_summary 或 list_child_runs 查看当前状态。"); - } else { - builder.append("\n- 如果现在需要统一对外回复,优先使用 FINAL_REPLY_ONCE: 前缀给出最终聚合结果。"); - builder.append("\n- 如果只需要结束内部编排、不需要外发,请返回 NO_REPLY。"); - } - return builder.toString(); + return builder.toString().trim(); } - /** - * 为当前运行构造子任务派生能力,并在子任务场景下应用默认的防扇出限制。 - * - * @param runId 当前运行标识 - * @param run 当前运行对象 - * @param inboundEnvelope 当前入站消息 - * @return 子任务派生能力 - */ private SpawnTaskSupport buildSpawnTaskSupport(String runId, AgentRun run, InboundEnvelope inboundEnvelope) { if (run == null) { return null; @@ -707,13 +445,6 @@ public class AgentRuntimeService { }; } - /** - * 在父运行上追加与子任务完成相关的结构化调试事件。 - * - * @param childRun 已完成的子任务 - * @param overallSummary 父运行下的全部子任务汇总 - * @param batchSummary 当前批次汇总 - */ private void appendParentChildCompletionEvents( AgentRun childRun, ParentRunChildrenSummary overallSummary, @@ -759,12 +490,6 @@ public class AgentRuntimeService { } } - /** - * 为当前会话构造任务状态查询能力。 - * - * @param sessionKey 会话键 - * @return 查询能力 - */ private RunQuerySupport buildRunQuerySupport(String sessionKey) { return new RunQuerySupport() { @Override @@ -809,13 +534,6 @@ public class AgentRuntimeService { }; } - /** - * 为当前会话构造主动通知能力。 - * - * @param sessionKey 会话键 - * @param runId 当前运行标识 - * @return 通知能力 - */ private NotificationSupport buildNotificationSupport(String sessionKey, String runId) { return (message, progress) -> { NotificationResult result = new NotificationResult(); @@ -848,17 +566,9 @@ 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; @@ -877,32 +587,10 @@ public class AgentRuntimeService { channelRegistry.send(outboundEnvelope); } - /** - * 判断当前回复是否表示“不要对外回复”。 - * - * @param response 最终回复 - * @return 若为 NO_REPLY 则返回 true - */ private boolean isNoReply(String response) { return StrUtil.equalsIgnoreCase(StrUtil.trim(response), NO_REPLY); } - /** - * 判断当前回复是否声明为“仅发送一次的最终汇总”。 - * - * @param response 最终回复 - * @return 若命中最终汇总前缀则返回 true - */ - private boolean isFinalReplyOnce(String response) { - return StrUtil.startWithIgnoreCase(StrUtil.trim(response), FINAL_REPLY_ONCE_PREFIX); - } - - /** - * 移除运行时保留前缀,得到真正对模型历史和外部渠道可见的回复文本。 - * - * @param response 原始回复 - * @return 可见回复 - */ private String normalizeVisibleResponse(String response) { String trimmed = StrUtil.trim(response); if (StrUtil.startWithIgnoreCase(trimmed, FINAL_REPLY_ONCE_PREFIX)) { @@ -911,69 +599,39 @@ public class AgentRuntimeService { return response; } - /** - * 若当前入站消息是子任务完成 continuation,则解析其父运行标识。 - * - * @param inboundEnvelope 入站消息 - * @return 父运行标识;否则返回 null - */ - private String resolveChildCompletionParentRunId(InboundEnvelope inboundEnvelope) { - if (inboundEnvelope == null || inboundEnvelope.getChannelType() != ChannelType.SYSTEM) { - return null; - } - String senderId = StrUtil.blankToDefault(inboundEnvelope.getSenderId(), ""); - String prefix = "child-complete:"; - return senderId.startsWith(prefix) ? senderId.substring(prefix.length()) : null; - } - - /** - * 对定时提醒类的静默系统触发提供一次运行时兜底投递。 - * 当模型没有显式调用 notify_user、但给出了面向用户的提醒文案时,由运行时代发一次。 - * - * @param runId 运行任务标识 - * @param inboundEnvelope 入站消息 - * @param visibleResponse 模型最终可见回复 - * @return 是否已通过兜底逻辑投递 - */ - private boolean maybeDeliverSilentReminder(String runId, InboundEnvelope inboundEnvelope, String visibleResponse) { - if (!isSilentReminderTrigger(inboundEnvelope)) { - return false; - } - if (StrUtil.isBlank(visibleResponse) || isNoReply(visibleResponse)) { - return false; - } - if (runtimeStoreService.hasRunEventType(runId, "notify") - || runtimeStoreService.hasRunEventType(runId, "notify_progress")) { - return false; - } - if (inboundEnvelope.getReplyTarget() == null || inboundEnvelope.getReplyTarget().isDebugWeb()) { - return false; - } - - OutboundEnvelope outboundEnvelope = new OutboundEnvelope(); - outboundEnvelope.setRunId(runId); - outboundEnvelope.setReplyTarget(inboundEnvelope.getReplyTarget()); - outboundEnvelope.setContent(visibleResponse); - channelRegistry.send(outboundEnvelope); - runtimeStoreService.appendRunEvent(runId, "silent_notify_fallback", visibleResponse); - return true; - } + private void handleEmptyUserResponse(AgentRun run, InboundEnvelope inboundEnvelope, String runId) { + String fallback = "这次处理没有拿到有效结果,可能是模型响应超时或解析异常。请再试一次。"; + run.setStatus(RunStatus.FAILED); + run.setFinishedAt(System.currentTimeMillis()); + run.setErrorMessage("模型未返回有效结果"); + run.setFinalResponse(fallback); + runtimeStoreService.saveRun(run); + runtimeStoreService.appendRunEvent(runId, "llm_empty_response", fallback); + runtimeStoreService.appendRunEvent(runId, "reply", fallback); + runtimeStoreService.appendRunEvent(runId, "status", "failed"); - /** - * 判断当前静默系统消息是否来自定时提醒触发。 - * - * @param inboundEnvelope 入站消息 - * @return 若为定时提醒触发则返回 true - */ - private boolean isSilentReminderTrigger(InboundEnvelope inboundEnvelope) { - return inboundEnvelope != null - && inboundEnvelope.getTriggerType() == InboundTriggerType.SYSTEM_SILENT - && StrUtil.contains( - StrUtil.blankToDefault(inboundEnvelope.getContent(), ""), - "[内部定时任务触发]" + runtimeStoreService.appendAssistantConversationEvent( + inboundEnvelope.getSessionKey(), + runId, + inboundEnvelope.getMessageId(), + inboundEnvelope.getHistoryAnchorVersion(), + RuntimeSourceKind.USER_MESSAGE, + fallback ); - } + if (inboundEnvelope.getReplyTarget() != null && !inboundEnvelope.getReplyTarget().isDebugWeb()) { + OutboundEnvelope outboundEnvelope = new OutboundEnvelope(); + outboundEnvelope.setRunId(runId); + outboundEnvelope.setReplyTarget(inboundEnvelope.getReplyTarget()); + outboundEnvelope.setContent(fallback); + channelRegistry.send(outboundEnvelope); + log.info( + "Run {} empty response fallback dispatched. channelType={}, conversationType={}, conversationId={}", + runId, + inboundEnvelope.getReplyTarget().getChannelType(), + inboundEnvelope.getReplyTarget().getConversationType(), + inboundEnvelope.getReplyTarget().getConversationId() + ); + } + } } - - diff --git a/src/main/java/com/jimuqu/claw/agent/runtime/impl/HeartbeatService.java b/src/main/java/com/jimuqu/claw/agent/runtime/impl/HeartbeatService.java index 3e6ddc0..0d1b555 100644 --- a/src/main/java/com/jimuqu/claw/agent/runtime/impl/HeartbeatService.java +++ b/src/main/java/com/jimuqu/claw/agent/runtime/impl/HeartbeatService.java @@ -2,8 +2,11 @@ package com.jimuqu.claw.agent.runtime.impl; import cn.hutool.core.io.FileUtil; import cn.hutool.core.util.StrUtil; +import com.jimuqu.claw.agent.model.enums.RuntimeSourceKind; +import com.jimuqu.claw.agent.model.enums.SystemEventPolicy; import com.jimuqu.claw.agent.model.route.LatestReplyRoute; import com.jimuqu.claw.agent.store.RuntimeStoreService; +import com.jimuqu.claw.agent.runtime.support.SystemEventRequest; import com.jimuqu.claw.config.SolonClawProperties; import com.jimuqu.claw.config.props.HeartbeatProperties; import org.slf4j.Logger; @@ -21,8 +24,8 @@ import java.util.concurrent.TimeUnit; public class HeartbeatService { /** 日志记录器。 */ private static final Logger log = LoggerFactory.getLogger(HeartbeatService.class); - /** Agent 运行时服务。 */ - private final AgentRuntimeService agentRuntimeService; + /** 系统事件执行器。 */ + private final SystemEventRunner systemEventRunner; /** 运行时存储服务。 */ private final RuntimeStoreService runtimeStoreService; /** 项目配置。 */ @@ -38,11 +41,11 @@ public class HeartbeatService { * @param properties 项目配置 */ public HeartbeatService( - AgentRuntimeService agentRuntimeService, + SystemEventRunner systemEventRunner, RuntimeStoreService runtimeStoreService, SolonClawProperties properties ) { - this.agentRuntimeService = agentRuntimeService; + this.systemEventRunner = systemEventRunner; this.runtimeStoreService = runtimeStoreService; this.properties = properties; } @@ -113,7 +116,15 @@ public class HeartbeatService { return; } - agentRuntimeService.submitSilentSystemMessage(route.getSessionKey(), route.getReplyTarget(), content, "heartbeat"); + SystemEventRequest request = new SystemEventRequest(); + request.setSourceKind(RuntimeSourceKind.HEARTBEAT_EVENT); + request.setPolicy(SystemEventPolicy.INTERNAL_ONLY); + request.setSessionKey(route.getSessionKey()); + request.setReplyTarget(route.getReplyTarget()); + request.setContent(content); + request.setAllowNotifyUser(true); + request.setWakeImmediately(true); + systemEventRunner.submit(request); } } diff --git a/src/main/java/com/jimuqu/claw/agent/runtime/impl/IsolatedAgentRunService.java b/src/main/java/com/jimuqu/claw/agent/runtime/impl/IsolatedAgentRunService.java new file mode 100644 index 0000000..10246f1 --- /dev/null +++ b/src/main/java/com/jimuqu/claw/agent/runtime/impl/IsolatedAgentRunService.java @@ -0,0 +1,271 @@ +package com.jimuqu.claw.agent.runtime.impl; + +import cn.hutool.core.util.StrUtil; +import com.jimuqu.claw.agent.channel.ChannelRegistry; +import com.jimuqu.claw.agent.job.AgentTurnSpec; +import com.jimuqu.claw.agent.job.JobDeliveryMode; +import com.jimuqu.claw.agent.model.enums.RuntimeSourceKind; +import com.jimuqu.claw.agent.model.enums.RunStatus; +import com.jimuqu.claw.agent.model.envelope.OutboundEnvelope; +import com.jimuqu.claw.agent.model.route.LatestReplyRoute; +import com.jimuqu.claw.agent.model.route.ReplyTarget; +import com.jimuqu.claw.agent.model.run.AgentRun; +import com.jimuqu.claw.agent.runtime.api.ConversationAgent; +import com.jimuqu.claw.agent.runtime.api.NotificationSupport; +import com.jimuqu.claw.agent.runtime.support.AgentTurnRequest; +import com.jimuqu.claw.agent.runtime.support.ConversationExecutionRequest; +import com.jimuqu.claw.agent.runtime.support.NotificationResult; +import com.jimuqu.claw.agent.store.RuntimeStoreService; +import com.jimuqu.claw.config.SolonClawProperties; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * 负责执行隔离 agent turn 类型的定时任务。 + */ +public class IsolatedAgentRunService { + private static final Logger log = LoggerFactory.getLogger(IsolatedAgentRunService.class); + + private final ConversationAgent conversationAgent; + private final RuntimeStoreService runtimeStoreService; + private final ConversationScheduler conversationScheduler; + private final ChannelRegistry channelRegistry; + private final SolonClawProperties properties; + + public IsolatedAgentRunService( + ConversationAgent conversationAgent, + RuntimeStoreService runtimeStoreService, + ConversationScheduler conversationScheduler, + ChannelRegistry channelRegistry, + SolonClawProperties properties + ) { + this.conversationAgent = conversationAgent; + this.runtimeStoreService = runtimeStoreService; + this.conversationScheduler = conversationScheduler; + this.channelRegistry = channelRegistry; + this.properties = properties; + } + + public String submit(AgentTurnRequest request) { + validate(request); + + AgentRun run = new AgentRun(); + run.setRunId(runtimeStoreService.newRunId()); + run.setSessionKey(buildIsolatedSessionKey(request, run.getRunId())); + run.setSourceMessageId("agent-turn-" + java.util.UUID.randomUUID().toString().replace("-", "")); + run.setSourceKind(RuntimeSourceKind.JOB_AGENT_TURN); + run.setTaskDescription(request.getAgentTurn().getMessage()); + run.setReplyTarget(request.getBoundReplyTarget()); + run.setStatus(RunStatus.QUEUED); + run.setCreatedAt(System.currentTimeMillis()); + runtimeStoreService.saveRun(run); + runtimeStoreService.appendRunEvent(run.getRunId(), "job_agent_turn_triggered", request.getAgentTurn().getMessage()); + runtimeStoreService.appendRunEvent(run.getRunId(), "status", "queued"); + + conversationScheduler.submit(run.getSessionKey(), () -> processRun(copyRequest(request), run.getRunId())); + return run.getRunId(); + } + + private void processRun(AgentTurnRequest request, String runId) { + AgentRun run = runtimeStoreService.getRun(runId); + if (run == null) { + return; + } + + try { + run.setStatus(RunStatus.RUNNING); + run.setStartedAt(System.currentTimeMillis()); + runtimeStoreService.saveRun(run); + runtimeStoreService.appendRunEvent(runId, "status", "running"); + + ConversationExecutionRequest executionRequest = new ConversationExecutionRequest(); + executionRequest.setSessionKey(run.getSessionKey()); + executionRequest.setCurrentMessage(request.getAgentTurn().getMessage()); + executionRequest.setCurrentSourceKind(RuntimeSourceKind.JOB_AGENT_TURN); + executionRequest.setNotificationSupport(buildNotificationSupport(request, runId)); + + final String[] latestProgress = {""}; + String response = conversationAgent.execute(executionRequest, progress -> { + latestProgress[0] = progress; + runtimeStoreService.appendRunEvent(runId, "progress", progress); + }); + if (StrUtil.isBlank(response)) { + response = latestProgress[0]; + } + + run = runtimeStoreService.getRun(runId); + if (run == null) { + return; + } + + String visibleResponse = StrUtil.blankToDefault(response, ""); + if (StrUtil.isBlank(visibleResponse)) { + handleEmptyAgentTurnResponse(run, runId, request); + return; + } + boolean notifyUsed = runtimeStoreService.hasRunEventType(runId, "notify") + || runtimeStoreService.hasRunEventType(runId, "notify_progress"); + boolean delivered = tryDeliverFinalReply(runId, request, visibleResponse, notifyUsed); + + run.setStatus(RunStatus.SUCCEEDED); + run.setFinishedAt(System.currentTimeMillis()); + run.setFinalResponse(visibleResponse); + runtimeStoreService.saveRun(run); + runtimeStoreService.appendRunEvent(runId, "reply", visibleResponse); + runtimeStoreService.appendRunEvent(runId, "status", "succeeded"); + + if (delivered) { + log.info("Isolated agent run {} delivered via {}", runId, request.getDeliveryMode()); + } + } catch (Throwable throwable) { + run.setStatus(RunStatus.FAILED); + run.setFinishedAt(System.currentTimeMillis()); + run.setErrorMessage(throwable.getMessage()); + runtimeStoreService.saveRun(run); + runtimeStoreService.appendRunEvent(runId, "error", throwable.getMessage()); + runtimeStoreService.appendRunEvent(runId, "status", "failed"); + log.warn("Isolated agent run {} failed: {}", runId, throwable.getMessage(), throwable); + } + } + + private boolean tryDeliverFinalReply( + String runId, + AgentTurnRequest request, + String visibleResponse, + boolean notifyUsed + ) { + if (notifyUsed || StrUtil.isBlank(visibleResponse) || isNoReply(visibleResponse)) { + return false; + } + + ReplyTarget replyTarget = resolveReplyTarget(request.getDeliveryMode(), request.getBoundReplyTarget()); + if (replyTarget == null || replyTarget.isDebugWeb()) { + runtimeStoreService.appendRunEvent(runId, "delivery_suppressed", visibleResponse); + return false; + } + + OutboundEnvelope outboundEnvelope = new OutboundEnvelope(); + outboundEnvelope.setRunId(runId); + outboundEnvelope.setReplyTarget(replyTarget); + outboundEnvelope.setContent(visibleResponse); + channelRegistry.send(outboundEnvelope); + runtimeStoreService.appendRunEvent(runId, "delivery_fallback_sent", visibleResponse); + return true; + } + + private NotificationSupport buildNotificationSupport(AgentTurnRequest request, String runId) { + if (request.getDeliveryMode() == JobDeliveryMode.NONE) { + return null; + } + + return (message, progress) -> { + NotificationResult result = new NotificationResult(); + result.setSessionKey(request.getBoundSessionKey()); + + if (StrUtil.isBlank(message)) { + result.setDelivered(false); + result.setMessage("message 不能为空"); + return result; + } + + ReplyTarget replyTarget = resolveReplyTarget(request.getDeliveryMode(), request.getBoundReplyTarget()); + if (replyTarget == null || replyTarget.isDebugWeb()) { + result.setDelivered(false); + result.setMessage("当前 agentTurn 没有可用的 ReplyTarget"); + return result; + } + + OutboundEnvelope outboundEnvelope = new OutboundEnvelope(); + outboundEnvelope.setRunId(runId); + outboundEnvelope.setReplyTarget(replyTarget); + outboundEnvelope.setContent(message); + outboundEnvelope.setProgress(progress); + channelRegistry.send(outboundEnvelope); + runtimeStoreService.appendRunEvent(runId, progress ? "notify_progress" : "notify", message); + + result.setDelivered(true); + result.setMessage("sent to " + replyTarget.getChannelType() + ":" + replyTarget.getConversationId()); + return result; + }; + } + + private ReplyTarget resolveReplyTarget(JobDeliveryMode deliveryMode, ReplyTarget boundReplyTarget) { + if (deliveryMode == JobDeliveryMode.NONE) { + return null; + } + if (deliveryMode == JobDeliveryMode.BOUND_REPLY_TARGET) { + return boundReplyTarget; + } + LatestReplyRoute latestReplyRoute = runtimeStoreService.getLatestExternalRoute(); + return latestReplyRoute == null ? null : latestReplyRoute.getReplyTarget(); + } + + private String buildIsolatedSessionKey(AgentTurnRequest request, String runId) { + String base = StrUtil.blankToDefault(StrUtil.trim(request.getJobName()), "agent-turn-job"); + return "job-agent:" + base + ":run:" + runId; + } + + private boolean isNoReply(String response) { + return StrUtil.equalsIgnoreCase(StrUtil.trim(response), AgentRuntimeService.NO_REPLY); + } + + private void validate(AgentTurnRequest request) { + if (request == null) { + throw new IllegalArgumentException("request 不能为空"); + } + if (request.getSourceKind() != RuntimeSourceKind.JOB_AGENT_TURN) { + throw new IllegalArgumentException("IsolatedAgentRunService 仅支持 JOB_AGENT_TURN"); + } + AgentTurnSpec agentTurn = request.getAgentTurn(); + if (agentTurn == null || StrUtil.isBlank(agentTurn.getMessage())) { + throw new IllegalArgumentException("agentTurn.message 不能为空"); + } + if (request.getDeliveryMode() == JobDeliveryMode.BOUND_REPLY_TARGET && request.getBoundReplyTarget() == null) { + throw new IllegalArgumentException("BOUND_REPLY_TARGET 模式必须提供 boundReplyTarget"); + } + } + + private AgentTurnRequest copyRequest(AgentTurnRequest request) { + AgentTurnRequest copy = new AgentTurnRequest(); + copy.setSourceKind(request.getSourceKind()); + copy.setJobName(request.getJobName()); + copy.setBoundSessionKey(request.getBoundSessionKey()); + copy.setBoundReplyTarget(request.getBoundReplyTarget()); + copy.setDeliveryMode(request.getDeliveryMode()); + + AgentTurnSpec spec = new AgentTurnSpec(); + if (request.getAgentTurn() != null) { + spec.setMessage(request.getAgentTurn().getMessage()); + spec.setModel(request.getAgentTurn().getModel()); + spec.setThinking(request.getAgentTurn().getThinking()); + spec.setTimeoutSeconds(request.getAgentTurn().getTimeoutSeconds()); + spec.setLightContext(request.getAgentTurn().isLightContext()); + } + copy.setAgentTurn(spec); + return copy; + } + + private void handleEmptyAgentTurnResponse(AgentRun run, String runId, AgentTurnRequest request) { + String fallback = "这次后台任务执行时没有拿到有效结果,可能是模型响应超时或解析异常。"; + run.setStatus(RunStatus.FAILED); + run.setFinishedAt(System.currentTimeMillis()); + run.setErrorMessage("模型未返回有效结果"); + run.setFinalResponse(fallback); + runtimeStoreService.saveRun(run); + runtimeStoreService.appendRunEvent(runId, "llm_empty_response", fallback); + runtimeStoreService.appendRunEvent(runId, "reply", fallback); + runtimeStoreService.appendRunEvent(runId, "status", "failed"); + + if (request.getDeliveryMode() != JobDeliveryMode.NONE) { + ReplyTarget replyTarget = resolveReplyTarget(request.getDeliveryMode(), request.getBoundReplyTarget()); + if (replyTarget != null && !replyTarget.isDebugWeb()) { + OutboundEnvelope outboundEnvelope = new OutboundEnvelope(); + outboundEnvelope.setRunId(runId); + outboundEnvelope.setReplyTarget(replyTarget); + outboundEnvelope.setContent(fallback); + channelRegistry.send(outboundEnvelope); + runtimeStoreService.appendRunEvent(runId, "delivery_fallback_sent", fallback); + } + } + } +} diff --git a/src/main/java/com/jimuqu/claw/agent/runtime/impl/SolonAiConversationAgent.java b/src/main/java/com/jimuqu/claw/agent/runtime/impl/SolonAiConversationAgent.java index 5ae5341..d3f2956 100644 --- a/src/main/java/com/jimuqu/claw/agent/runtime/impl/SolonAiConversationAgent.java +++ b/src/main/java/com/jimuqu/claw/agent/runtime/impl/SolonAiConversationAgent.java @@ -1,6 +1,7 @@ package com.jimuqu.claw.agent.runtime.impl; -import com.jimuqu.claw.agent.model.enums.InboundTriggerType; +import cn.hutool.core.util.StrUtil; +import com.jimuqu.claw.agent.model.enums.RuntimeSourceKind; import com.jimuqu.claw.agent.runtime.api.ConversationAgent; import com.jimuqu.claw.agent.runtime.support.ConversationExecutionRequest; import com.jimuqu.claw.agent.runtime.support.SystemAwareAgentSession; @@ -9,7 +10,7 @@ import com.jimuqu.claw.agent.tool.ConversationRuntimeTools; import com.jimuqu.claw.agent.tool.JobTools; import com.jimuqu.claw.agent.tool.WorkspaceAgentTools; import com.jimuqu.claw.agent.workspace.WorkspacePromptService; -import cn.hutool.core.util.StrUtil; +import lombok.Setter; import org.noear.solon.ai.agent.AgentChunk; import org.noear.solon.ai.agent.react.ReActAgent; import org.noear.solon.ai.agent.react.ReActInterceptor; @@ -48,7 +49,8 @@ public class SolonAiConversationAgent implements ConversationAgent { /** * 定时任务工具。 */ - private final JobTools jobTools; + @Setter + private JobTools jobTools; /** * ReAct 运行日志拦截器。 */ @@ -68,14 +70,12 @@ public class SolonAiConversationAgent implements ConversationAgent { WorkspacePromptService workspacePromptService, WorkspaceAgentTools workspaceAgentTools, CliSkillProvider cliSkillProvider, - JobTools jobTools, ReActInterceptor reActInterceptor ) { this.chatModel = chatModel; this.workspacePromptService = workspacePromptService; this.workspaceAgentTools = workspaceAgentTools; this.cliSkillProvider = cliSkillProvider; - this.jobTools = jobTools; this.reActInterceptor = reActInterceptor; } @@ -152,7 +152,7 @@ public class SolonAiConversationAgent implements ConversationAgent { .retryConfig(5, 1000L) .sessionWindowSize(64); - if (shouldEnableJobTools(request)) { + if (jobTools != null && shouldEnableJobTools(request)) { builder.defaultToolAdd(jobTools); } @@ -160,65 +160,56 @@ public class SolonAiConversationAgent implements ConversationAgent { } /** - * 为不同类型的触发生成合适的当前轮次提示。 + * 静默系统触发只用于内部检查或定时任务执行,不应再次管理定时任务本身。 + * + * @param request 当前执行请求 + * @return 是否挂载定时任务管理工具 + */ + private boolean shouldEnableJobTools(ConversationExecutionRequest request) { + if (request == null) { + return true; + } + return request.getCurrentSourceKind() == RuntimeSourceKind.USER_MESSAGE; + } + + /** + * 为不同来源类型生成合适的当前轮次提示。 * * @param request 当前执行请求 * @param session 会话上下文 * @return 当前轮次提示 */ private String resolvePrompt(ConversationExecutionRequest request, SystemAwareAgentSession session) { - InboundTriggerType triggerType = request == null ? InboundTriggerType.USER : request.getCurrentMessageTriggerType(); + RuntimeSourceKind sourceKind = request == null ? RuntimeSourceKind.USER_MESSAGE : request.getCurrentSourceKind(); String currentMessage = request == null ? null : request.getCurrentMessage(); - if (triggerType == null || triggerType == InboundTriggerType.USER) { + if (sourceKind == RuntimeSourceKind.USER_MESSAGE) { return currentMessage; } - if (currentMessage != null && currentMessage.trim().length() > 0) { + if (StrUtil.isNotBlank(currentMessage)) { session.addMessage(ChatMessage.ofSystem(currentMessage)); } - if (triggerType == InboundTriggerType.SYSTEM_SILENT) { - if (isScheduledReminderTrigger(currentMessage)) { - return "一个定时提醒已触发。提醒内容见最新的 system 消息。" - + "请把这条提醒自然友好地告知用户,不要把它当作新的用户消息。" - + "如果你已经通过 notify_user 发送了提醒,请返回 NO_REPLY。" - + "如果你直接给出面向用户的提醒文案,运行时会代为发送一次。" - + "除非用户明确要求,否则不要解释内部触发过程。"; - } - - return "这是一条内部事件,相关结果见最新的 system 消息。" - + "请先在内部处理,不要把它当作新的用户消息。" - + "除非用户明确要求,否则不要把这次内部处理过程转告用户。" - + "如果没有需要面向用户的后续动作,请直接返回 NO_REPLY。"; + if (sourceKind == RuntimeSourceKind.JOB_SYSTEM_EVENT) { + return "一条定时任务事件已触发,具体内容见最新的 system 消息。" + + "请直接处理这条内部事件;如果需要提醒用户,就生成一条自然友好的提醒文本," + + "或显式调用 notify_user。不要解释内部机制。"; } - - return "这是一条内部系统事件,相关内容见最新的 system 消息。" - + "请结合既有上下文继续处理,不要把它当作新的用户消息。" - + "只有在确实需要用户看到结果时,才直接给出面向用户的最终回复。"; - } - - /** - * 静默系统触发只用于内部检查或定时任务执行,不应再次管理定时任务本身。 - * - * @param request 当前执行请求 - * @return 是否挂载定时任务管理工具 - */ - private boolean shouldEnableJobTools(ConversationExecutionRequest request) { - if (request == null) { - return true; + if (sourceKind == RuntimeSourceKind.HEARTBEAT_EVENT) { + return "这是一条心跳内部检查事件,相关内容见最新的 system 消息。" + + "请先在内部处理;如果没有明确的用户可见动作,请直接返回 NO_REPLY。"; + } + if (sourceKind == RuntimeSourceKind.CHILD_CONTINUATION) { + return "一条子任务 continuation 事件已到达,结构化结果见最新的 system 消息。" + + "请结合当前会话上下文继续聚合处理;如果还不能给用户最终答复,请返回 NO_REPLY。" + + "如果需要最终聚合回复,请使用 FINAL_REPLY_ONCE: 前缀。"; + } + if (sourceKind == RuntimeSourceKind.JOB_AGENT_TURN) { + return "这是一次隔离的自动化 agent turn。请根据当前任务描述完成工作。" + + "不要把它当作新的用户对话,也不要解释内部调度过程。"; } - return request.getCurrentMessageTriggerType() != InboundTriggerType.SYSTEM_SILENT; - } - /** - * 判断当前静默事件是否为定时提醒触发。 - * - * @param currentMessage 当前消息文本 - * @return 若为定时提醒触发则返回 true - */ - private boolean isScheduledReminderTrigger(String currentMessage) { - return StrUtil.contains(StrUtil.blankToDefault(currentMessage, ""), "[内部定时任务触发]"); + return currentMessage; } } - diff --git a/src/main/java/com/jimuqu/claw/agent/runtime/impl/SystemEventRunner.java b/src/main/java/com/jimuqu/claw/agent/runtime/impl/SystemEventRunner.java new file mode 100644 index 0000000..7d03a9c --- /dev/null +++ b/src/main/java/com/jimuqu/claw/agent/runtime/impl/SystemEventRunner.java @@ -0,0 +1,413 @@ +package com.jimuqu.claw.agent.runtime.impl; + +import cn.hutool.core.util.StrUtil; +import com.jimuqu.claw.agent.channel.ChannelRegistry; +import com.jimuqu.claw.agent.model.enums.RuntimeSourceKind; +import com.jimuqu.claw.agent.model.enums.SystemEventPolicy; +import com.jimuqu.claw.agent.model.enums.RunStatus; +import com.jimuqu.claw.agent.model.envelope.OutboundEnvelope; +import com.jimuqu.claw.agent.model.route.ReplyTarget; +import com.jimuqu.claw.agent.model.run.AgentRun; +import com.jimuqu.claw.agent.runtime.api.ConversationAgent; +import com.jimuqu.claw.agent.runtime.api.NotificationSupport; +import com.jimuqu.claw.agent.runtime.api.RunQuerySupport; +import com.jimuqu.claw.agent.runtime.support.ConversationExecutionRequest; +import com.jimuqu.claw.agent.runtime.support.NotificationResult; +import com.jimuqu.claw.agent.runtime.support.ParentRunChildrenSummary; +import com.jimuqu.claw.agent.runtime.support.SystemEventRequest; +import com.jimuqu.claw.agent.store.RuntimeStoreService; +import com.jimuqu.claw.config.SolonClawProperties; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; +import java.util.Queue; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.TimeUnit; + +/** + * 负责执行 systemEvent、heartbeat 和 child continuation 等内部事件。 + */ +public class SystemEventRunner { + private static final Logger log = LoggerFactory.getLogger(SystemEventRunner.class); + + private final ConversationAgent conversationAgent; + private final RuntimeStoreService runtimeStoreService; + private final ConversationScheduler conversationScheduler; + private final ChannelRegistry channelRegistry; + private final SolonClawProperties properties; + private final Queue pendingRequests = new ConcurrentLinkedQueue(); + private ScheduledExecutorService scheduler; + + public SystemEventRunner( + ConversationAgent conversationAgent, + RuntimeStoreService runtimeStoreService, + ConversationScheduler conversationScheduler, + ChannelRegistry channelRegistry, + SolonClawProperties properties + ) { + this.conversationAgent = conversationAgent; + this.runtimeStoreService = runtimeStoreService; + this.conversationScheduler = conversationScheduler; + this.channelRegistry = channelRegistry; + this.properties = properties; + } + + public void start() { + if (scheduler != null) { + return; + } + + ThreadFactory threadFactory = runnable -> { + Thread thread = new Thread(runnable, "solonclaw-system-event-runner"); + thread.setDaemon(true); + return thread; + }; + scheduler = Executors.newSingleThreadScheduledExecutor(threadFactory); + scheduler.scheduleWithFixedDelay(this::safeDrainPending, 1L, 1L, TimeUnit.SECONDS); + } + + public void stop() { + if (scheduler != null) { + scheduler.shutdownNow(); + scheduler = null; + } + } + + public String submit(SystemEventRequest request) { + validate(request); + if (request.isWakeImmediately()) { + return dispatch(request); + } + + pendingRequests.offer(copyRequest(request)); + return null; + } + + private void safeDrainPending() { + try { + drainPending(); + } catch (Throwable throwable) { + log.warn("Failed to drain pending system events: {}", throwable.getMessage(), throwable); + } + } + + private void drainPending() { + SystemEventRequest request; + while ((request = pendingRequests.poll()) != null) { + dispatch(request); + } + } + + private String dispatch(SystemEventRequest request) { + long sourceUserVersion = request.getSourceUserVersion() > 0 + ? request.getSourceUserVersion() + : runtimeStoreService.getLatestUserConversationVersion(request.getSessionKey()); + + AgentRun run = new AgentRun(); + run.setRunId(runtimeStoreService.newRunId()); + run.setSessionKey(request.getSessionKey()); + run.setSourceMessageId("system-event-" + java.util.UUID.randomUUID().toString().replace("-", "")); + run.setSourceKind(request.getSourceKind()); + run.setSourceUserVersion(sourceUserVersion); + run.setReplyTarget(request.getReplyTarget()); + run.setRelatedRunId(request.getRelatedRunId()); + run.setStatus(RunStatus.QUEUED); + run.setCreatedAt(System.currentTimeMillis()); + runtimeStoreService.saveRun(run); + runtimeStoreService.appendRunEvent(run.getRunId(), eventTypeFor(request.getSourceKind()), request.getContent()); + runtimeStoreService.appendRunEvent(run.getRunId(), "status", "queued"); + + conversationScheduler.submit( + request.getSessionKey(), + () -> processRun(copyRequest(request), run.getRunId()) + ); + return run.getRunId(); + } + + private void processRun(SystemEventRequest request, String runId) { + AgentRun run = runtimeStoreService.getRun(runId); + if (run == null) { + return; + } + + try { + run.setStatus(RunStatus.RUNNING); + run.setStartedAt(System.currentTimeMillis()); + runtimeStoreService.saveRun(run); + runtimeStoreService.appendRunEvent(runId, "status", "running"); + + ConversationExecutionRequest executionRequest = new ConversationExecutionRequest(); + executionRequest.setSessionKey(request.getSessionKey()); + executionRequest.setCurrentMessage(request.getContent()); + executionRequest.setCurrentSourceKind(request.getSourceKind()); + executionRequest.setHistory(runtimeStoreService.loadConversationHistoryBefore( + request.getSessionKey(), + runtimeStoreService.getLatestConversationVersion(request.getSessionKey()) + 1L + )); + executionRequest.setRunQuerySupport(buildRunQuerySupport(request.getSessionKey())); + executionRequest.setNotificationSupport(request.isAllowNotifyUser() + ? buildNotificationSupport(request.getSessionKey(), request.getReplyTarget(), runId) + : null); + + final String[] latestProgress = {""}; + String response = conversationAgent.execute(executionRequest, progress -> { + latestProgress[0] = progress; + runtimeStoreService.appendRunEvent(runId, "progress", progress); + }); + if (StrUtil.isBlank(response)) { + response = latestProgress[0]; + } + + run = runtimeStoreService.getRun(runId); + if (run == null) { + return; + } + + String visibleResponse = normalizeVisibleResponse(response); + if (StrUtil.isBlank(visibleResponse)) { + run.setStatus(RunStatus.FAILED); + run.setFinishedAt(System.currentTimeMillis()); + run.setErrorMessage("模型未返回有效结果"); + runtimeStoreService.saveRun(run); + runtimeStoreService.appendRunEvent(runId, "llm_empty_response", "system event 未返回有效结果"); + runtimeStoreService.appendRunEvent(runId, "status", "failed"); + return; + } + boolean notifyUsed = runtimeStoreService.hasRunEventType(runId, "notify") + || runtimeStoreService.hasRunEventType(runId, "notify_progress"); + boolean delivered = false; + + if (request.getPolicy() == SystemEventPolicy.AGGREGATE_ONLY) { + delivered = tryDeliverAggregateReply(runId, request, response, visibleResponse); + } else if (request.getPolicy() == SystemEventPolicy.USER_VISIBLE_OPTIONAL) { + delivered = tryDeliverUserVisibleReply(runId, request, response, visibleResponse, notifyUsed); + } else if (!notifyUsed && StrUtil.isNotBlank(visibleResponse) && !isNoReply(response)) { + runtimeStoreService.appendRunEvent(runId, "delivery_suppressed", visibleResponse); + } + + run.setStatus(RunStatus.SUCCEEDED); + run.setFinishedAt(System.currentTimeMillis()); + run.setFinalResponse(visibleResponse); + runtimeStoreService.saveRun(run); + runtimeStoreService.appendRunEvent(runId, "reply", visibleResponse); + runtimeStoreService.appendRunEvent(runId, "status", "succeeded"); + + if (delivered && request.getPolicy() == SystemEventPolicy.AGGREGATE_ONLY) { + runtimeStoreService.appendAssistantConversationEvent( + request.getSessionKey(), + runId, + run.getSourceMessageId(), + run.getSourceUserVersion(), + run.getSourceKind(), + visibleResponse + ); + } + } catch (Throwable throwable) { + run.setStatus(RunStatus.FAILED); + run.setFinishedAt(System.currentTimeMillis()); + run.setErrorMessage(throwable.getMessage()); + runtimeStoreService.saveRun(run); + runtimeStoreService.appendRunEvent(runId, "error", throwable.getMessage()); + runtimeStoreService.appendRunEvent(runId, "status", "failed"); + log.warn("System event run {} failed: {}", runId, throwable.getMessage(), throwable); + } + } + + private boolean tryDeliverUserVisibleReply( + String runId, + SystemEventRequest request, + String response, + String visibleResponse, + boolean notifyUsed + ) { + if (notifyUsed || isNoReply(response) || StrUtil.isBlank(visibleResponse)) { + return false; + } + if (request.getReplyTarget() == null || request.getReplyTarget().isDebugWeb()) { + runtimeStoreService.appendRunEvent(runId, "delivery_suppressed", visibleResponse); + return false; + } + + OutboundEnvelope outboundEnvelope = new OutboundEnvelope(); + outboundEnvelope.setRunId(runId); + outboundEnvelope.setReplyTarget(request.getReplyTarget()); + outboundEnvelope.setContent(visibleResponse); + channelRegistry.send(outboundEnvelope); + runtimeStoreService.appendRunEvent(runId, "delivery_fallback_sent", visibleResponse); + return true; + } + + private boolean tryDeliverAggregateReply( + String runId, + SystemEventRequest request, + String response, + String visibleResponse + ) { + if (StrUtil.isBlank(request.getRelatedRunId())) { + return false; + } + if (!isFinalReplyOnce(response) || StrUtil.isBlank(visibleResponse)) { + if (!isNoReply(response) && StrUtil.isNotBlank(visibleResponse)) { + runtimeStoreService.appendRunEvent(runId, "delivery_suppressed", visibleResponse); + } + return false; + } + if (runtimeStoreService.hasRunEventType(request.getRelatedRunId(), "children_aggregated")) { + runtimeStoreService.appendRunEvent(runId, "delivery_suppressed", "already_aggregated"); + return false; + } + ParentRunChildrenSummary summary = runtimeStoreService.summarizeChildRuns(request.getRelatedRunId(), null); + if (summary.getTotalChildren() == 0 || !summary.isAllCompleted()) { + runtimeStoreService.appendRunEvent(runId, "delivery_suppressed", "children_not_completed"); + return false; + } + if (request.getReplyTarget() == null || request.getReplyTarget().isDebugWeb()) { + runtimeStoreService.appendRunEvent(runId, "delivery_suppressed", "missing_reply_target"); + return false; + } + + OutboundEnvelope outboundEnvelope = new OutboundEnvelope(); + outboundEnvelope.setRunId(runId); + outboundEnvelope.setReplyTarget(request.getReplyTarget()); + outboundEnvelope.setContent(visibleResponse); + channelRegistry.send(outboundEnvelope); + runtimeStoreService.appendRunEvent(request.getRelatedRunId(), "children_aggregated", "aggregateRunId=" + runId); + runtimeStoreService.appendRunEvent(runId, "delivery_fallback_sent", visibleResponse); + return true; + } + + private NotificationSupport buildNotificationSupport(String sessionKey, ReplyTarget replyTarget, String runId) { + return (message, progress) -> { + NotificationResult result = new NotificationResult(); + result.setSessionKey(sessionKey); + + if (StrUtil.isBlank(message)) { + result.setDelivered(false); + result.setMessage("message 不能为空"); + return result; + } + if (replyTarget == null || replyTarget.isDebugWeb()) { + result.setDelivered(false); + result.setMessage("当前系统事件没有可用的 ReplyTarget"); + return result; + } + + OutboundEnvelope outboundEnvelope = new OutboundEnvelope(); + outboundEnvelope.setRunId(runId); + outboundEnvelope.setReplyTarget(replyTarget); + outboundEnvelope.setContent(message); + outboundEnvelope.setProgress(progress); + channelRegistry.send(outboundEnvelope); + runtimeStoreService.appendRunEvent(runId, progress ? "notify_progress" : "notify", message); + + result.setDelivered(true); + result.setMessage("sent to " + replyTarget.getChannelType() + ":" + replyTarget.getConversationId()); + return result; + }; + } + + private RunQuerySupport buildRunQuerySupport(String sessionKey) { + return new RunQuerySupport() { + @Override + public List listChildRuns(int limit) { + return runtimeStoreService.listChildRuns(sessionKey, limit); + } + + @Override + public AgentRun getRun(String runId) { + AgentRun run = runtimeStoreService.getRun(runId); + if (run == null) { + return null; + } + if (StrUtil.equals(sessionKey, run.getParentSessionKey()) || StrUtil.equals(sessionKey, run.getSessionKey())) { + return run; + } + return null; + } + + @Override + public AgentRun getLatestChildRun() { + return runtimeStoreService.getLatestChildRun(sessionKey); + } + + @Override + public ParentRunChildrenSummary getChildSummary(String parentRunId, String batchKey) { + String resolvedParentRunId = parentRunId; + if (StrUtil.isBlank(resolvedParentRunId)) { + AgentRun latestParent = runtimeStoreService.getLatestParentRunWithChildren(sessionKey); + resolvedParentRunId = latestParent == null ? null : latestParent.getRunId(); + } + if (StrUtil.isBlank(resolvedParentRunId)) { + return null; + } + + ParentRunChildrenSummary summary = runtimeStoreService.summarizeChildRuns( + resolvedParentRunId, + StrUtil.blankToDefault(StrUtil.trim(batchKey), null) + ); + return summary.getTotalChildren() == 0 ? null : summary; + } + }; + } + + private void validate(SystemEventRequest request) { + if (request == null) { + throw new IllegalArgumentException("request 不能为空"); + } + if (request.getSourceKind() != RuntimeSourceKind.JOB_SYSTEM_EVENT + && request.getSourceKind() != RuntimeSourceKind.HEARTBEAT_EVENT + && request.getSourceKind() != RuntimeSourceKind.CHILD_CONTINUATION) { + throw new IllegalArgumentException("SystemEventRunner 仅支持 system event 来源"); + } + if (StrUtil.isBlank(request.getSessionKey())) { + throw new IllegalArgumentException("sessionKey 不能为空"); + } + if (StrUtil.isBlank(request.getContent())) { + throw new IllegalArgumentException("content 不能为空"); + } + } + + private String eventTypeFor(RuntimeSourceKind sourceKind) { + if (sourceKind == RuntimeSourceKind.JOB_SYSTEM_EVENT) { + return "job_system_event_triggered"; + } + if (sourceKind == RuntimeSourceKind.HEARTBEAT_EVENT) { + return "heartbeat_event_triggered"; + } + return "child_continuation_triggered"; + } + + private boolean isNoReply(String response) { + return StrUtil.equalsIgnoreCase(StrUtil.trim(response), AgentRuntimeService.NO_REPLY); + } + + private boolean isFinalReplyOnce(String response) { + return StrUtil.startWithIgnoreCase(StrUtil.trim(response), AgentRuntimeService.FINAL_REPLY_ONCE_PREFIX); + } + + private String normalizeVisibleResponse(String response) { + String trimmed = StrUtil.trim(response); + if (StrUtil.startWithIgnoreCase(trimmed, AgentRuntimeService.FINAL_REPLY_ONCE_PREFIX)) { + return StrUtil.trim(trimmed.substring(AgentRuntimeService.FINAL_REPLY_ONCE_PREFIX.length())); + } + return response; + } + + private SystemEventRequest copyRequest(SystemEventRequest request) { + SystemEventRequest copy = new SystemEventRequest(); + copy.setSourceKind(request.getSourceKind()); + copy.setPolicy(request.getPolicy()); + copy.setSessionKey(request.getSessionKey()); + copy.setReplyTarget(request.getReplyTarget()); + copy.setContent(request.getContent()); + copy.setSourceUserVersion(request.getSourceUserVersion()); + copy.setRelatedRunId(request.getRelatedRunId()); + copy.setAllowNotifyUser(request.isAllowNotifyUser()); + copy.setWakeImmediately(request.isWakeImmediately()); + return copy; + } +} diff --git a/src/main/java/com/jimuqu/claw/agent/runtime/support/AgentTurnRequest.java b/src/main/java/com/jimuqu/claw/agent/runtime/support/AgentTurnRequest.java new file mode 100644 index 0000000..44eb7a1 --- /dev/null +++ b/src/main/java/com/jimuqu/claw/agent/runtime/support/AgentTurnRequest.java @@ -0,0 +1,26 @@ +package com.jimuqu.claw.agent.runtime.support; + +import com.jimuqu.claw.agent.job.AgentTurnSpec; +import com.jimuqu.claw.agent.job.JobDeliveryMode; +import com.jimuqu.claw.agent.model.enums.RuntimeSourceKind; +import com.jimuqu.claw.agent.model.route.ReplyTarget; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +/** + * 描述一次隔离 agent turn 执行请求。 + */ +@Data +@NoArgsConstructor +public class AgentTurnRequest implements Serializable { + private static final long serialVersionUID = 1L; + + private RuntimeSourceKind sourceKind = RuntimeSourceKind.JOB_AGENT_TURN; + private String jobName; + private String boundSessionKey; + private ReplyTarget boundReplyTarget; + private JobDeliveryMode deliveryMode = JobDeliveryMode.NONE; + private AgentTurnSpec agentTurn = new AgentTurnSpec(); +} diff --git a/src/main/java/com/jimuqu/claw/agent/runtime/support/ConversationExecutionRequest.java b/src/main/java/com/jimuqu/claw/agent/runtime/support/ConversationExecutionRequest.java index 38cf54e..896ab49 100644 --- a/src/main/java/com/jimuqu/claw/agent/runtime/support/ConversationExecutionRequest.java +++ b/src/main/java/com/jimuqu/claw/agent/runtime/support/ConversationExecutionRequest.java @@ -1,6 +1,6 @@ package com.jimuqu.claw.agent.runtime.support; -import com.jimuqu.claw.agent.model.enums.InboundTriggerType; +import com.jimuqu.claw.agent.model.enums.RuntimeSourceKind; import com.jimuqu.claw.agent.runtime.api.NotificationSupport; import com.jimuqu.claw.agent.runtime.api.RunQuerySupport; import com.jimuqu.claw.agent.runtime.api.SpawnTaskSupport; @@ -24,8 +24,8 @@ public class ConversationExecutionRequest implements Serializable { private String sessionKey; /** 当前待处理的用户消息。 */ private String currentMessage; - /** 当前消息的触发类型。 */ - private InboundTriggerType currentMessageTriggerType = InboundTriggerType.USER; + /** 当前消息的来源类型。 */ + private RuntimeSourceKind currentSourceKind = RuntimeSourceKind.USER_MESSAGE; /** 当前运行是否为父任务派生出的子任务。 */ private boolean childRun; /** 当前子任务对应的父运行标识。 */ diff --git a/src/main/java/com/jimuqu/claw/agent/runtime/support/SystemAwareAgentSession.java b/src/main/java/com/jimuqu/claw/agent/runtime/support/SystemAwareAgentSession.java index 3b5e7c9..5c5dc57 100644 --- a/src/main/java/com/jimuqu/claw/agent/runtime/support/SystemAwareAgentSession.java +++ b/src/main/java/com/jimuqu/claw/agent/runtime/support/SystemAwareAgentSession.java @@ -13,6 +13,11 @@ public class SystemAwareAgentSession extends InMemoryChatSession implements Agen /** 当前执行快照。 */ private volatile FlowContext snapshot; + @Override + public FlowContext getContext() { + return snapshot; + } + /** * 创建带默认窗口大小的会话。 * diff --git a/src/main/java/com/jimuqu/claw/agent/runtime/support/SystemEventRequest.java b/src/main/java/com/jimuqu/claw/agent/runtime/support/SystemEventRequest.java new file mode 100644 index 0000000..a6385b5 --- /dev/null +++ b/src/main/java/com/jimuqu/claw/agent/runtime/support/SystemEventRequest.java @@ -0,0 +1,28 @@ +package com.jimuqu.claw.agent.runtime.support; + +import com.jimuqu.claw.agent.model.enums.RuntimeSourceKind; +import com.jimuqu.claw.agent.model.enums.SystemEventPolicy; +import com.jimuqu.claw.agent.model.route.ReplyTarget; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +/** + * 描述一次系统事件执行请求。 + */ +@Data +@NoArgsConstructor +public class SystemEventRequest implements Serializable { + private static final long serialVersionUID = 1L; + + private RuntimeSourceKind sourceKind; + private SystemEventPolicy policy = SystemEventPolicy.INTERNAL_ONLY; + private String sessionKey; + private ReplyTarget replyTarget; + private String content; + private long sourceUserVersion; + private String relatedRunId; + private boolean allowNotifyUser; + private boolean wakeImmediately = true; +} 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 b5f4556..bcf8358 100644 --- a/src/main/java/com/jimuqu/claw/agent/store/RuntimeStoreService.java +++ b/src/main/java/com/jimuqu/claw/agent/store/RuntimeStoreService.java @@ -8,9 +8,9 @@ import com.jimuqu.claw.agent.model.run.AgentRun; import com.jimuqu.claw.agent.model.event.ChildRunCompletedData; import com.jimuqu.claw.agent.model.event.ChildRunSpawnedData; import com.jimuqu.claw.agent.model.enums.ChannelType; +import com.jimuqu.claw.agent.model.enums.RuntimeSourceKind; import com.jimuqu.claw.agent.model.event.ConversationEvent; import com.jimuqu.claw.agent.model.envelope.InboundEnvelope; -import com.jimuqu.claw.agent.model.enums.InboundTriggerType; import com.jimuqu.claw.agent.model.route.LatestReplyRoute; import com.jimuqu.claw.agent.model.route.ReplyTarget; import com.jimuqu.claw.agent.model.event.RunEvent; @@ -116,6 +116,7 @@ public class RuntimeStoreService { ConversationEvent event = new ConversationEvent(); event.setSessionKey(inboundEnvelope.getSessionKey()); event.setEventType(resolveInboundEventType(inboundEnvelope)); + event.setSourceKind(inboundEnvelope.getSourceKind()); event.setSourceMessageId(inboundEnvelope.getMessageId()); event.setSourceUserVersion(resolveInboundSourceUserVersion(inboundEnvelope)); event.setRole(resolveInboundEventRole(inboundEnvelope)); @@ -134,11 +135,19 @@ public class RuntimeStoreService { * @param content 回复内容 * @return 新事件版本号 */ - public long appendAssistantConversationEvent(String sessionKey, String runId, String sourceMessageId, long sourceUserVersion, String content) { + public long appendAssistantConversationEvent( + String sessionKey, + String runId, + String sourceMessageId, + long sourceUserVersion, + RuntimeSourceKind sourceKind, + String content + ) { ConversationEvent event = new ConversationEvent(); event.setSessionKey(sessionKey); event.setEventType("assistant_reply"); event.setRunId(runId); + event.setSourceKind(sourceKind); event.setSourceMessageId(sourceMessageId); event.setSourceUserVersion(sourceUserVersion); event.setRole("assistant"); @@ -160,6 +169,7 @@ public class RuntimeStoreService { event.setSessionKey(sessionKey); event.setEventType("system_event"); event.setRunId(runId); + event.setSourceKind(RuntimeSourceKind.CHILD_CONTINUATION); event.setRole("system"); event.setContent(content); event.setCreatedAt(System.currentTimeMillis()); @@ -187,6 +197,7 @@ public class RuntimeStoreService { event.setSessionKey(sessionKey); event.setEventType("child_run_spawned"); event.setRunId(parentRunId); + event.setSourceKind(RuntimeSourceKind.CHILD_CONTINUATION); event.setSourceUserVersion(sourceUserVersion); event.setRole("system"); event.setContent("子任务已创建"); @@ -219,6 +230,7 @@ public class RuntimeStoreService { event.setSessionKey(sessionKey); event.setEventType("child_run_completed"); event.setRunId(parentRunId); + event.setSourceKind(RuntimeSourceKind.CHILD_CONTINUATION); event.setSourceUserVersion(sourceUserVersion); event.setRole("system"); event.setContent("子任务已完成"); @@ -378,6 +390,10 @@ public class RuntimeStoreService { try { RunEvent runEvent = new RunEvent(); runEvent.setRunId(runId); + AgentRun agentRun = getRun(runId); + if (agentRun != null) { + runEvent.setSourceKind(agentRun.getSourceKind()); + } runEvent.setEventType(eventType); runEvent.setMessage(message); runEvent.setCreatedAt(System.currentTimeMillis()); @@ -887,8 +903,8 @@ public class RuntimeStoreService { * @return 会话事件类型 */ private String resolveInboundEventType(InboundEnvelope inboundEnvelope) { - InboundTriggerType triggerType = inboundEnvelope == null ? null : inboundEnvelope.getTriggerType(); - if (triggerType == null || triggerType == InboundTriggerType.USER) { + RuntimeSourceKind sourceKind = inboundEnvelope == null ? null : inboundEnvelope.getSourceKind(); + if (sourceKind == null || sourceKind == RuntimeSourceKind.USER_MESSAGE) { return "user_message"; } return "system_event"; @@ -901,8 +917,8 @@ public class RuntimeStoreService { * @return 会话事件角色 */ private String resolveInboundEventRole(InboundEnvelope inboundEnvelope) { - InboundTriggerType triggerType = inboundEnvelope == null ? null : inboundEnvelope.getTriggerType(); - if (triggerType == null || triggerType == InboundTriggerType.USER) { + RuntimeSourceKind sourceKind = inboundEnvelope == null ? null : inboundEnvelope.getSourceKind(); + if (sourceKind == null || sourceKind == RuntimeSourceKind.USER_MESSAGE) { return "user"; } return "system"; @@ -915,8 +931,8 @@ public class RuntimeStoreService { * @return 历史锚点版本 */ private long resolveInboundSourceUserVersion(InboundEnvelope inboundEnvelope) { - InboundTriggerType triggerType = inboundEnvelope == null ? null : inboundEnvelope.getTriggerType(); - if (triggerType == null || triggerType == InboundTriggerType.USER) { + RuntimeSourceKind sourceKind = inboundEnvelope == null ? null : inboundEnvelope.getSourceKind(); + if (sourceKind == null || sourceKind == RuntimeSourceKind.USER_MESSAGE) { return 0L; } return inboundEnvelope.getHistoryAnchorVersion(); diff --git a/src/main/java/com/jimuqu/claw/agent/tool/JobTools.java b/src/main/java/com/jimuqu/claw/agent/tool/JobTools.java index 7a6ee5a..754a686 100644 --- a/src/main/java/com/jimuqu/claw/agent/tool/JobTools.java +++ b/src/main/java/com/jimuqu/claw/agent/tool/JobTools.java @@ -1,7 +1,10 @@ package com.jimuqu.claw.agent.tool; import cn.hutool.json.JSONUtil; +import com.jimuqu.claw.agent.job.AgentTurnSpec; +import com.jimuqu.claw.agent.job.JobDeliveryMode; import com.jimuqu.claw.agent.job.JobDefinition; +import com.jimuqu.claw.agent.job.JobWakeMode; import com.jimuqu.claw.agent.job.WorkspaceJobService; import org.noear.solon.ai.annotation.ToolMapping; import org.noear.solon.annotation.Param; @@ -28,17 +31,60 @@ public class JobTools { return definition == null ? "任务不存在: " + name : JSONUtil.toJsonPrettyStr(definition); } - @ToolMapping(name = "add_job", description = "新增定时任务。mode 仅支持 fixed_rate、fixed_delay、once_delay、cron;fixed_* 与 once_delay 的 scheduleValue 单位为毫秒") - public String addJob( + @ToolMapping(name = "add_system_job", description = "新增 systemEvent 定时任务。mode 仅支持 fixed_rate、fixed_delay、once_delay、cron;fixed_* 与 once_delay 的 scheduleValue 单位为毫秒") + public String addSystemJob( @Param(description = "任务名称") String name, @Param(description = "调度模式:fixed_rate、fixed_delay、once_delay、cron") String mode, @Param(description = "调度值:cron 表达式或毫秒值") String scheduleValue, - @Param(description = "触发时提交给 Agent 的任务提示词") String prompt, + @Param(description = "触发时注入主会话的 system event 文本") String systemEventText, @Param(description = "首次执行前延迟毫秒数,可填 0") long initialDelay, - @Param(description = "时区,可为空") String zone + @Param(description = "时区,可为空") String zone, + @Param(description = "唤醒模式:NOW 或 NEXT_TICK,可为空") String wakeMode ) { return JSONUtil.toJsonPrettyStr( - workspaceJobService.addJob(name, mode, scheduleValue, prompt, initialDelay, zone) + workspaceJobService.addSystemJob( + name, + mode, + scheduleValue, + systemEventText, + initialDelay, + zone, + parseWakeMode(wakeMode) + ) + ); + } + + @ToolMapping(name = "add_agent_job", description = "新增 agentTurn 定时任务。mode 仅支持 fixed_rate、fixed_delay、once_delay、cron;fixed_* 与 once_delay 的 scheduleValue 单位为毫秒") + public String addAgentJob( + @Param(description = "任务名称") String name, + @Param(description = "调度模式:fixed_rate、fixed_delay、once_delay、cron") String mode, + @Param(description = "调度值:cron 表达式或毫秒值") String scheduleValue, + @Param(description = "agentTurn 的任务描述") String message, + @Param(description = "首次执行前延迟毫秒数,可填 0") long initialDelay, + @Param(description = "时区,可为空") String zone, + @Param(description = "投递策略:NONE、BOUND_REPLY_TARGET、LAST_ROUTE") String deliveryMode, + @Param(description = "可选模型覆盖") String model, + @Param(description = "可选思考强度") String thinking, + @Param(description = "可选超时秒数") Integer timeoutSeconds, + @Param(description = "是否使用轻量上下文,可填 true/false") Boolean lightContext + ) { + AgentTurnSpec agentTurnSpec = new AgentTurnSpec(); + agentTurnSpec.setMessage(message); + agentTurnSpec.setModel(model); + agentTurnSpec.setThinking(thinking); + agentTurnSpec.setTimeoutSeconds(timeoutSeconds); + agentTurnSpec.setLightContext(lightContext != null && lightContext); + + return JSONUtil.toJsonPrettyStr( + workspaceJobService.addAgentJob( + name, + mode, + scheduleValue, + agentTurnSpec, + initialDelay, + zone, + parseDeliveryMode(deliveryMode) + ) ); } @@ -56,4 +102,18 @@ public class JobTools { public String stopJob(@Param(description = "任务名称") String name) throws ScheduledException { return JSONUtil.toJsonPrettyStr(workspaceJobService.stopJob(name)); } + + private JobWakeMode parseWakeMode(String wakeMode) { + if (wakeMode == null || wakeMode.trim().isEmpty()) { + return null; + } + return JobWakeMode.valueOf(wakeMode.trim().toUpperCase()); + } + + private JobDeliveryMode parseDeliveryMode(String deliveryMode) { + if (deliveryMode == null || deliveryMode.trim().isEmpty()) { + return null; + } + return JobDeliveryMode.valueOf(deliveryMode.trim().toUpperCase()); + } } diff --git a/src/main/java/com/jimuqu/claw/config/SolonClawConfig.java b/src/main/java/com/jimuqu/claw/config/SolonClawConfig.java index e2bde32..62287ef 100644 --- a/src/main/java/com/jimuqu/claw/config/SolonClawConfig.java +++ b/src/main/java/com/jimuqu/claw/config/SolonClawConfig.java @@ -7,8 +7,10 @@ import com.jimuqu.claw.agent.runtime.impl.AgentRuntimeService; import com.jimuqu.claw.agent.runtime.api.ConversationAgent; import com.jimuqu.claw.agent.runtime.impl.ConversationScheduler; import com.jimuqu.claw.agent.runtime.impl.HeartbeatService; +import com.jimuqu.claw.agent.runtime.impl.IsolatedAgentRunService; import com.jimuqu.claw.agent.runtime.impl.ReActLoggingInterceptor; import com.jimuqu.claw.agent.runtime.impl.SolonAiConversationAgent; +import com.jimuqu.claw.agent.runtime.impl.SystemEventRunner; import com.jimuqu.claw.agent.store.RuntimeStoreService; import com.jimuqu.claw.agent.tool.JobTools; import com.jimuqu.claw.agent.tool.WorkspaceAgentTools; @@ -113,17 +115,25 @@ public class SolonClawConfig { * * @param jobManager 任务管理器 * @param jobStoreService 定时任务存储服务 - * @param runtimeStoreService 运行时存储服务 - * @param agentRuntimeService Agent 运行时服务 * @return 工作区定时任务服务 */ @Bean(initMethod = "restorePersistedJobs") public WorkspaceJobService workspaceJobService( IJobManager jobManager, JobStoreService jobStoreService, - RuntimeStoreService runtimeStoreService + RuntimeStoreService runtimeStoreService, + SystemEventRunner systemEventRunner, + IsolatedAgentRunService isolatedAgentRunService, + SolonClawProperties properties ) { - return new WorkspaceJobService(jobManager, jobStoreService, runtimeStoreService); + return new WorkspaceJobService( + jobManager, + jobStoreService, + runtimeStoreService, + systemEventRunner, + isolatedAgentRunService, + properties + ); } /** @@ -133,8 +143,13 @@ public class SolonClawConfig { * @return 定时任务工具 */ @Bean - public JobTools jobTools(WorkspaceJobService workspaceJobService) { - return new JobTools(workspaceJobService); + public JobTools jobTools( + WorkspaceJobService workspaceJobService, + SolonAiConversationAgent conversationAgent + ) { + JobTools jobTools = new JobTools(workspaceJobService); + conversationAgent.setJobTools(jobTools); + return jobTools; } /** @@ -190,12 +205,11 @@ public class SolonClawConfig { * @return 会话执行 Agent */ @Bean - public ConversationAgent conversationAgent( + public SolonAiConversationAgent conversationAgent( ChatModel chatModel, WorkspacePromptService workspacePromptService, WorkspaceAgentTools workspaceAgentTools, CliSkillProvider cliSkillProvider, - JobTools jobTools, ReActInterceptor reActLoggingInterceptor ) { return new SolonAiConversationAgent( @@ -203,7 +217,6 @@ public class SolonClawConfig { workspacePromptService, workspaceAgentTools, cliSkillProvider, - jobTools, reActLoggingInterceptor ); } @@ -218,6 +231,40 @@ public class SolonClawConfig { return new ChannelRegistry(); } + @Bean(initMethod = "start", destroyMethod = "stop") + public SystemEventRunner systemEventRunner( + ConversationAgent conversationAgent, + RuntimeStoreService runtimeStoreService, + ConversationScheduler conversationScheduler, + ChannelRegistry channelRegistry, + SolonClawProperties properties + ) { + return new SystemEventRunner( + conversationAgent, + runtimeStoreService, + conversationScheduler, + channelRegistry, + properties + ); + } + + @Bean + public IsolatedAgentRunService isolatedAgentRunService( + ConversationAgent conversationAgent, + RuntimeStoreService runtimeStoreService, + ConversationScheduler conversationScheduler, + ChannelRegistry channelRegistry, + SolonClawProperties properties + ) { + return new IsolatedAgentRunService( + conversationAgent, + runtimeStoreService, + conversationScheduler, + channelRegistry, + properties + ); + } + /** * 创建飞书消息发送服务。 * @@ -272,18 +319,17 @@ public class SolonClawConfig { RuntimeStoreService runtimeStoreService, ConversationScheduler conversationScheduler, ChannelRegistry channelRegistry, - WorkspaceJobService workspaceJobService, + SystemEventRunner systemEventRunner, SolonClawProperties properties ) { - AgentRuntimeService service = new AgentRuntimeService( + return new AgentRuntimeService( conversationAgent, runtimeStoreService, conversationScheduler, channelRegistry, + systemEventRunner, properties ); - workspaceJobService.setJobDispatcher(service::submitSilentSystemMessage); - return service; } /** @@ -347,11 +393,11 @@ public class SolonClawConfig { */ @Bean(initMethod = "start", destroyMethod = "stop") public HeartbeatService heartbeatService( - AgentRuntimeService agentRuntimeService, + SystemEventRunner systemEventRunner, RuntimeStoreService runtimeStoreService, SolonClawProperties properties ) { - return new HeartbeatService(agentRuntimeService, runtimeStoreService, properties); + return new HeartbeatService(systemEventRunner, runtimeStoreService, properties); } } diff --git a/src/main/java/com/jimuqu/claw/config/props/AgentProperties.java b/src/main/java/com/jimuqu/claw/config/props/AgentProperties.java index dc5e457..e1b2679 100644 --- a/src/main/java/com/jimuqu/claw/config/props/AgentProperties.java +++ b/src/main/java/com/jimuqu/claw/config/props/AgentProperties.java @@ -23,4 +23,10 @@ public class AgentProperties implements Serializable { private SubtasksProperties subtasks = new SubtasksProperties(); /** 心跳配置。 */ private HeartbeatProperties heartbeat = new HeartbeatProperties(); + /** 系统事件执行配置。 */ + private SystemEventsProperties systemEvents = new SystemEventsProperties(); + /** 定时任务执行配置。 */ + private JobsProperties jobs = new JobsProperties(); + /** 隔离 agent turn 配置。 */ + private AgentTurnProperties agentTurn = new AgentTurnProperties(); } diff --git a/src/main/java/com/jimuqu/claw/config/props/AgentTurnProperties.java b/src/main/java/com/jimuqu/claw/config/props/AgentTurnProperties.java new file mode 100644 index 0000000..dce563b --- /dev/null +++ b/src/main/java/com/jimuqu/claw/config/props/AgentTurnProperties.java @@ -0,0 +1,18 @@ +package com.jimuqu.claw.config.props; + +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +/** + * 描述隔离 agent turn 的默认配置。 + */ +@Data +@NoArgsConstructor +public class AgentTurnProperties implements Serializable { + private static final long serialVersionUID = 1L; + + /** 默认超时时间,单位秒。 */ + private int defaultTimeoutSeconds = 300; +} diff --git a/src/main/java/com/jimuqu/claw/config/props/JobsProperties.java b/src/main/java/com/jimuqu/claw/config/props/JobsProperties.java new file mode 100644 index 0000000..7b63016 --- /dev/null +++ b/src/main/java/com/jimuqu/claw/config/props/JobsProperties.java @@ -0,0 +1,22 @@ +package com.jimuqu.claw.config.props; + +import com.jimuqu.claw.agent.job.JobDeliveryMode; +import com.jimuqu.claw.agent.job.JobWakeMode; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +/** + * 描述定时任务默认行为配置。 + */ +@Data +@NoArgsConstructor +public class JobsProperties implements Serializable { + private static final long serialVersionUID = 1L; + + /** systemEvent 任务默认唤醒模式。 */ + private JobWakeMode defaultWakeMode = JobWakeMode.NOW; + /** agentTurn 任务默认投递策略。 */ + private JobDeliveryMode defaultDeliveryMode = JobDeliveryMode.BOUND_REPLY_TARGET; +} diff --git a/src/main/java/com/jimuqu/claw/config/props/SystemEventsProperties.java b/src/main/java/com/jimuqu/claw/config/props/SystemEventsProperties.java new file mode 100644 index 0000000..bb4d96f --- /dev/null +++ b/src/main/java/com/jimuqu/claw/config/props/SystemEventsProperties.java @@ -0,0 +1,19 @@ +package com.jimuqu.claw.config.props; + +import com.jimuqu.claw.agent.model.enums.SystemEventPolicy; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +/** + * 描述系统事件执行默认配置。 + */ +@Data +@NoArgsConstructor +public class SystemEventsProperties implements Serializable { + private static final long serialVersionUID = 1L; + + /** 通用 system event 的默认策略。 */ + private SystemEventPolicy defaultPolicy = SystemEventPolicy.INTERNAL_ONLY; +} diff --git a/src/test/java/com/jimuqu/claw/agent/job/JobStoreServiceTest.java b/src/test/java/com/jimuqu/claw/agent/job/JobStoreServiceTest.java index e1122e5..d016daf 100644 --- a/src/test/java/com/jimuqu/claw/agent/job/JobStoreServiceTest.java +++ b/src/test/java/com/jimuqu/claw/agent/job/JobStoreServiceTest.java @@ -23,9 +23,13 @@ class JobStoreServiceTest { definition.setName("demo"); definition.setMode("once_delay"); definition.setScheduleValue("1000"); - definition.setPrompt("hello"); - definition.setSessionKey("dingtalk:private:demo"); - definition.setReplyTarget(new ReplyTarget(ChannelType.DINGTALK, ConversationType.PRIVATE, "cid", "uid")); + definition.setPayloadKind(JobPayloadKind.SYSTEM_EVENT); + definition.setSessionTarget(JobSessionTarget.MAIN); + definition.setWakeMode(JobWakeMode.NOW); + definition.setDeliveryMode(JobDeliveryMode.NONE); + definition.setSystemEventText("hello"); + definition.setBoundSessionKey("dingtalk:private:demo"); + definition.setBoundReplyTarget(new ReplyTarget(ChannelType.DINGTALK, ConversationType.PRIVATE, "cid", "uid")); definition.setEnabled(true); definition.setCreatedAt(1L); definition.setUpdatedAt(2L); @@ -36,7 +40,8 @@ class JobStoreServiceTest { assertNotNull(saved); assertEquals("once_delay", saved.getMode()); assertEquals("1000", saved.getScheduleValue()); - assertEquals("hello", saved.getPrompt()); + assertEquals(JobPayloadKind.SYSTEM_EVENT, saved.getPayloadKind()); + assertEquals("hello", saved.getSystemEventText()); assertTrue(storeService.getJobsFile().exists()); } } diff --git a/src/test/java/com/jimuqu/claw/agent/job/WorkspaceJobServiceTest.java b/src/test/java/com/jimuqu/claw/agent/job/WorkspaceJobServiceTest.java new file mode 100644 index 0000000..bad7d8a --- /dev/null +++ b/src/test/java/com/jimuqu/claw/agent/job/WorkspaceJobServiceTest.java @@ -0,0 +1,332 @@ +package com.jimuqu.claw.agent.job; + +import com.jimuqu.claw.agent.channel.ChannelRegistry; +import com.jimuqu.claw.agent.model.enums.ChannelType; +import com.jimuqu.claw.agent.model.enums.ConversationType; +import com.jimuqu.claw.agent.model.enums.RuntimeSourceKind; +import com.jimuqu.claw.agent.model.enums.SystemEventPolicy; +import com.jimuqu.claw.agent.model.route.ReplyTarget; +import com.jimuqu.claw.agent.runtime.api.ConversationAgent; +import com.jimuqu.claw.agent.runtime.impl.ConversationScheduler; +import com.jimuqu.claw.agent.runtime.impl.IsolatedAgentRunService; +import com.jimuqu.claw.agent.runtime.impl.SystemEventRunner; +import com.jimuqu.claw.agent.runtime.support.AgentTurnRequest; +import com.jimuqu.claw.agent.runtime.support.SystemEventRequest; +import com.jimuqu.claw.agent.store.RuntimeStoreService; +import com.jimuqu.claw.agent.workspace.AgentWorkspaceService; +import com.jimuqu.claw.config.SolonClawProperties; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.noear.solon.core.Lifecycle; +import org.noear.solon.core.util.RankEntity; +import org.noear.solon.scheduling.ScheduledException; +import org.noear.solon.scheduling.annotation.Scheduled; +import org.noear.solon.scheduling.scheduled.JobHandler; +import org.noear.solon.scheduling.scheduled.JobHolder; +import org.noear.solon.scheduling.scheduled.JobInterceptor; +import org.noear.solon.scheduling.scheduled.manager.IJobManager; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class WorkspaceJobServiceTest { + @TempDir + Path tempDir; + + @Test + void addSystemJobPersistsNewPayloadFields() { + TestContext ctx = new TestContext(tempDir); + + JobDefinition definition = ctx.workspaceJobService.addSystemJob( + "drink-water", + "fixed_rate", + "60000", + "提醒我喝水", + 0L, + "Asia/Shanghai", + JobWakeMode.NOW + ); + + assertEquals(JobPayloadKind.SYSTEM_EVENT, definition.getPayloadKind()); + assertEquals(JobSessionTarget.MAIN, definition.getSessionTarget()); + assertEquals(JobWakeMode.NOW, definition.getWakeMode()); + assertEquals(JobDeliveryMode.NONE, definition.getDeliveryMode()); + assertEquals("提醒我喝水", definition.getSystemEventText()); + assertEquals(ctx.boundSessionKey, definition.getBoundSessionKey()); + assertEquals(ctx.boundReplyTarget.getConversationId(), definition.getBoundReplyTarget().getConversationId()); + assertNotNull(ctx.jobStoreService.get("drink-water")); + } + + @Test + void addAgentJobPersistsAgentTurnFields() { + TestContext ctx = new TestContext(tempDir); + AgentTurnSpec spec = new AgentTurnSpec(); + spec.setMessage("检查服务器状态"); + spec.setModel("qwen"); + spec.setThinking("medium"); + spec.setTimeoutSeconds(120); + spec.setLightContext(true); + + JobDefinition definition = ctx.workspaceJobService.addAgentJob( + "check-server", + "fixed_rate", + "60000", + spec, + 0L, + "Asia/Shanghai", + JobDeliveryMode.LAST_ROUTE + ); + + assertEquals(JobPayloadKind.AGENT_TURN, definition.getPayloadKind()); + assertEquals(JobSessionTarget.ISOLATED, definition.getSessionTarget()); + assertEquals(JobDeliveryMode.LAST_ROUTE, definition.getDeliveryMode()); + assertEquals("检查服务器状态", definition.getAgentTurn().getMessage()); + assertEquals("qwen", definition.getAgentTurn().getModel()); + assertTrue(definition.getAgentTurn().isLightContext()); + } + + @Test + void invalidSystemEventAndAgentTurnCombinationsAreRejected() { + TestContext ctx = new TestContext(tempDir); + + JobDefinition invalidSystem = new JobDefinition(); + invalidSystem.setName("bad-system"); + invalidSystem.setMode("fixed_rate"); + invalidSystem.setScheduleValue("60000"); + invalidSystem.setPayloadKind(JobPayloadKind.SYSTEM_EVENT); + invalidSystem.setSessionTarget(JobSessionTarget.ISOLATED); + invalidSystem.setWakeMode(JobWakeMode.NOW); + invalidSystem.setDeliveryMode(JobDeliveryMode.NONE); + invalidSystem.setBoundSessionKey(ctx.boundSessionKey); + invalidSystem.setBoundReplyTarget(ctx.boundReplyTarget); + invalidSystem.setSystemEventText("bad"); + ctx.jobStoreService.save(invalidSystem); + + JobDefinition invalidAgent = new JobDefinition(); + invalidAgent.setName("bad-agent"); + invalidAgent.setMode("fixed_rate"); + invalidAgent.setScheduleValue("60000"); + invalidAgent.setPayloadKind(JobPayloadKind.AGENT_TURN); + invalidAgent.setSessionTarget(JobSessionTarget.MAIN); + invalidAgent.setDeliveryMode(JobDeliveryMode.BOUND_REPLY_TARGET); + invalidAgent.setBoundSessionKey(ctx.boundSessionKey); + invalidAgent.setBoundReplyTarget(ctx.boundReplyTarget); + AgentTurnSpec spec = new AgentTurnSpec(); + spec.setMessage("bad"); + invalidAgent.setAgentTurn(spec); + ctx.jobStoreService.save(invalidAgent); + + assertThrows(IllegalArgumentException.class, () -> ctx.workspaceJobService.startJob("bad-system")); + assertThrows(IllegalArgumentException.class, () -> ctx.workspaceJobService.startJob("bad-agent")); + } + + @Test + void schedulerDispatchesSystemEventAndAgentTurnToDifferentRunners() throws Exception { + TestContext ctx = new TestContext(tempDir); + AgentTurnSpec spec = new AgentTurnSpec(); + spec.setMessage("收集日志"); + + ctx.workspaceJobService.addSystemJob( + "system-job", + "fixed_rate", + "60000", + "提醒我站起来活动一下", + 0L, + "Asia/Shanghai", + JobWakeMode.NOW + ); + ctx.workspaceJobService.addAgentJob( + "agent-job", + "fixed_rate", + "60000", + spec, + 0L, + "Asia/Shanghai", + JobDeliveryMode.BOUND_REPLY_TARGET + ); + + ctx.jobManager.trigger("system-job"); + ctx.jobManager.trigger("agent-job"); + + assertNotNull(ctx.systemEventRunner.lastRequest.get()); + assertEquals(RuntimeSourceKind.JOB_SYSTEM_EVENT, ctx.systemEventRunner.lastRequest.get().getSourceKind()); + assertEquals(SystemEventPolicy.USER_VISIBLE_OPTIONAL, ctx.systemEventRunner.lastRequest.get().getPolicy()); + assertEquals("提醒我站起来活动一下", ctx.systemEventRunner.lastRequest.get().getContent()); + + assertNotNull(ctx.isolatedAgentRunService.lastRequest.get()); + assertEquals(RuntimeSourceKind.JOB_AGENT_TURN, ctx.isolatedAgentRunService.lastRequest.get().getSourceKind()); + assertEquals("收集日志", ctx.isolatedAgentRunService.lastRequest.get().getAgentTurn().getMessage()); + assertEquals(JobDeliveryMode.BOUND_REPLY_TARGET, ctx.isolatedAgentRunService.lastRequest.get().getDeliveryMode()); + } + + private static final class TestContext { + private final ReplyTarget boundReplyTarget; + private final String boundSessionKey; + private final TestJobManager jobManager; + private final JobStoreService jobStoreService; + private final RuntimeStoreService runtimeStoreService; + private final CapturingSystemEventRunner systemEventRunner; + private final CapturingIsolatedAgentRunService isolatedAgentRunService; + private final WorkspaceJobService workspaceJobService; + private final ConversationScheduler scheduler; + + private TestContext(Path tempDir) { + SolonClawProperties properties = new SolonClawProperties(); + AgentWorkspaceService workspaceService = new AgentWorkspaceService(tempDir.toString()); + this.jobManager = new TestJobManager(); + this.jobStoreService = new JobStoreService(workspaceService); + this.runtimeStoreService = new RuntimeStoreService(tempDir.resolve("runtime").toFile()); + this.scheduler = new ConversationScheduler(1); + this.boundReplyTarget = new ReplyTarget(ChannelType.DINGTALK, ConversationType.PRIVATE, "cid", "uid"); + this.boundSessionKey = "dingtalk:private:cid"; + this.runtimeStoreService.rememberReplyTarget(boundSessionKey, boundReplyTarget); + + ConversationAgent noopAgent = (request, progressConsumer) -> "noop"; + ChannelRegistry registry = new ChannelRegistry(); + this.systemEventRunner = new CapturingSystemEventRunner(noopAgent, runtimeStoreService, scheduler, registry, properties); + this.isolatedAgentRunService = new CapturingIsolatedAgentRunService(noopAgent, runtimeStoreService, scheduler, registry, properties); + this.workspaceJobService = new WorkspaceJobService( + jobManager, + jobStoreService, + runtimeStoreService, + systemEventRunner, + isolatedAgentRunService, + properties + ); + } + } + + private static class CapturingSystemEventRunner extends SystemEventRunner { + private final AtomicReference lastRequest = new AtomicReference(); + + private CapturingSystemEventRunner( + ConversationAgent conversationAgent, + RuntimeStoreService runtimeStoreService, + ConversationScheduler conversationScheduler, + ChannelRegistry channelRegistry, + SolonClawProperties properties + ) { + super(conversationAgent, runtimeStoreService, conversationScheduler, channelRegistry, properties); + } + + @Override + public String submit(SystemEventRequest request) { + lastRequest.set(request); + return "captured-system"; + } + } + + private static class CapturingIsolatedAgentRunService extends IsolatedAgentRunService { + private final AtomicReference lastRequest = new AtomicReference(); + + private CapturingIsolatedAgentRunService( + ConversationAgent conversationAgent, + RuntimeStoreService runtimeStoreService, + ConversationScheduler conversationScheduler, + ChannelRegistry channelRegistry, + SolonClawProperties properties + ) { + super(conversationAgent, runtimeStoreService, conversationScheduler, channelRegistry, properties); + } + + @Override + public String submit(AgentTurnRequest request) { + lastRequest.set(request); + return "captured-agent"; + } + } + + private static class TestJobManager implements IJobManager { + private final Map jobs = new LinkedHashMap(); + + @Override + public void start() { + } + + @Override + public void stop() { + } + + @Override + public void addJobInterceptor(int index, JobInterceptor interceptor) { + } + + @Override + public boolean hasJobInterceptor() { + return false; + } + + @Override + public List> getJobInterceptors() { + return new ArrayList>(); + } + + @Override + public JobHolder jobAdd(String name, Scheduled scheduled, JobHandler handler) { + JobHolder holder = new JobHolder(this, name, scheduled, handler); + jobs.put(name, holder); + return holder; + } + + @Override + public JobHolder jobAdd(String name, Scheduled scheduled, JobHandler handler, Map data) { + JobHolder holder = jobAdd(name, scheduled, handler); + holder.setData(data); + return holder; + } + + @Override + public boolean jobExists(String name) { + return jobs.containsKey(name); + } + + @Override + public JobHolder jobGet(String name) { + return jobs.get(name); + } + + @Override + public Map jobGetAll() { + return jobs; + } + + @Override + public void jobRemove(String name) { + jobs.remove(name); + } + + @Override + public void jobStart(String name, Map data) throws ScheduledException { + } + + @Override + public void jobStop(String name) throws ScheduledException { + } + + @Override + public boolean isStarted() { + return true; + } + + private void trigger(String name) throws Exception { + JobHolder holder = jobs.get(name); + if (holder == null) { + throw new IllegalArgumentException("job 不存在: " + name); + } + try { + holder.getHandler().handle(null); + } catch (Throwable throwable) { + throw new RuntimeException(throwable); + } + } + } +} 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 7dfbe55..0025da4 100644 --- a/src/test/java/com/jimuqu/claw/agent/runtime/AgentRuntimeServiceTest.java +++ b/src/test/java/com/jimuqu/claw/agent/runtime/AgentRuntimeServiceTest.java @@ -2,22 +2,23 @@ package com.jimuqu.claw.agent.runtime; import com.jimuqu.claw.agent.channel.ChannelAdapter; import com.jimuqu.claw.agent.channel.ChannelRegistry; -import com.jimuqu.claw.agent.model.run.AgentRun; -import com.jimuqu.claw.agent.model.enums.ChannelType; -import com.jimuqu.claw.agent.model.event.ConversationEvent; -import com.jimuqu.claw.agent.model.enums.ConversationType; import com.jimuqu.claw.agent.model.envelope.InboundEnvelope; -import com.jimuqu.claw.agent.model.enums.InboundTriggerType; import com.jimuqu.claw.agent.model.envelope.OutboundEnvelope; -import com.jimuqu.claw.agent.model.route.ReplyTarget; +import com.jimuqu.claw.agent.model.enums.ChannelType; +import com.jimuqu.claw.agent.model.enums.ConversationType; +import com.jimuqu.claw.agent.model.enums.RuntimeSourceKind; import com.jimuqu.claw.agent.model.enums.RunStatus; +import com.jimuqu.claw.agent.model.event.RunEvent; +import com.jimuqu.claw.agent.model.route.ReplyTarget; +import com.jimuqu.claw.agent.model.run.AgentRun; import com.jimuqu.claw.agent.runtime.api.ConversationAgent; import com.jimuqu.claw.agent.runtime.impl.AgentRuntimeService; import com.jimuqu.claw.agent.runtime.impl.ConversationScheduler; +import com.jimuqu.claw.agent.runtime.impl.SystemEventRunner; import com.jimuqu.claw.agent.runtime.support.ConversationExecutionRequest; import com.jimuqu.claw.agent.runtime.support.NotificationResult; -import com.jimuqu.claw.agent.store.RuntimeStoreService; import com.jimuqu.claw.agent.runtime.support.ParentRunChildrenSummary; +import com.jimuqu.claw.agent.store.RuntimeStoreService; import com.jimuqu.claw.config.SolonClawProperties; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -27,27 +28,19 @@ 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.concurrent.atomic.AtomicReference; import java.util.function.BooleanSupplier; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; -/** - * 验证 Agent 运行时的并发调度和忙时回执行为。 - */ class AgentRuntimeServiceTest { - /** 临时测试目录。 */ @TempDir Path tempDir; - /** - * 验证同会话繁忙时第二条消息会收到即时回执。 - * - * @throws Exception 执行异常 - */ @Test void secondMessageGetsImmediateAckWhenConversationBusy() throws Exception { CountDownLatch firstStarted = new CountDownLatch(1); @@ -69,25 +62,21 @@ class AgentRuntimeServiceTest { ChannelRegistry registry = new ChannelRegistry(); RecordingChannelAdapter adapter = new RecordingChannelAdapter(); registry.register(adapter); - SolonClawProperties properties = new SolonClawProperties(); properties.getAgent().getScheduler().setMaxConcurrentPerConversation(1); properties.getAgent().getScheduler().setAckWhenBusy(true); + AgentRuntimeService runtimeService = runtimeService(conversationAgent, store, scheduler, registry, properties); try { - AgentRuntimeService runtimeService = new AgentRuntimeService(conversationAgent, store, scheduler, registry, properties); - String firstRunId = runtimeService.submitInbound(inbound("msg-1", "question-1")); assertTrue(firstStarted.await(2, TimeUnit.SECONDS)); String secondRunId = runtimeService.submitInbound(inbound("msg-2", "question-2")); assertNotNull(firstRunId); assertNotNull(secondRunId); - assertTrue(waitUntil(() -> adapter.messages.stream().anyMatch(message -> message.contains("已收到")), 2000)); releaseFirst.countDown(); - assertTrue(waitUntil(() -> { AgentRun run1 = runtimeService.getRun(firstRunId); AgentRun run2 = runtimeService.getRun(secondRunId); @@ -95,240 +84,12 @@ class AgentRuntimeServiceTest { && run1.getStatus() == RunStatus.SUCCEEDED && run2.getStatus() == RunStatus.SUCCEEDED; }, 5000)); - - assertEquals(3, adapter.outbounds.size()); - assertEquals("reply-question-1", runtimeService.getRun(firstRunId).getFinalResponse()); - assertEquals("reply-question-2", runtimeService.getRun(secondRunId).getFinalResponse()); } finally { releaseFirst.countDown(); scheduler.shutdown(); } } - /** - * 验证父运行可派生子任务,子任务完成后会触发父会话 continuation run。 - * - * @throws Exception 执行异常 - */ - @Test - void childRunCompletionContinuesParentConversation() throws Exception { - RuntimeStoreService store = new RuntimeStoreService(tempDir.toFile()); - ConversationScheduler scheduler = new ConversationScheduler(1); - ChannelRegistry registry = new ChannelRegistry(); - RecordingChannelAdapter adapter = new RecordingChannelAdapter(); - registry.register(adapter); - - ConversationAgent conversationAgent = (request, progressConsumer) -> { - String message = request.getCurrentMessage(); - if ("question-parent".equals(message)) { - request.getSpawnTaskSupport().spawnTask("research-child"); - progressConsumer.accept("spawned"); - return "parent-waiting"; - } - if ("research-child".equals(message)) { - progressConsumer.accept("child-running"); - return "child-result"; - } - if (message != null && message.contains("[内部事件] 子任务已完成")) { - return "final-parent-answer"; - } - return "reply-" + message; - }; - - SolonClawProperties properties = new SolonClawProperties(); - properties.getAgent().getScheduler().setMaxConcurrentPerConversation(1); - properties.getAgent().getScheduler().setAckWhenBusy(false); - - try { - AgentRuntimeService runtimeService = new AgentRuntimeService(conversationAgent, store, scheduler, registry, properties); - String parentRunId = runtimeService.submitInbound(inbound("msg-parent", "question-parent")); - assertNotNull(parentRunId); - - assertTrue(waitUntil(() -> { - AgentRun parentRun = runtimeService.getRun(parentRunId); - return parentRun != null && parentRun.getStatus() == RunStatus.WAITING_CHILDREN; - }, 3000)); - - assertTrue(waitUntil(() -> adapter.messages.contains("final-parent-answer"), 5000)); - - assertEquals(1, adapter.outbounds.size()); - assertEquals("final-parent-answer", adapter.outbounds.get(0).getContent()); - assertEquals(RunStatus.WAITING_CHILDREN, runtimeService.getRun(parentRunId).getStatus()); - } finally { - scheduler.shutdown(); - } - } - - /** - * 验证子任务默认不能继续派生新的子任务,避免无边界扇出。 - * - * @throws Exception 执行异常 - */ - @Test - void childRunCannotSpawnNestedChildByDefault() throws Exception { - RuntimeStoreService store = new RuntimeStoreService(tempDir.toFile()); - ConversationScheduler scheduler = new ConversationScheduler(1); - ChannelRegistry registry = new ChannelRegistry(); - RecordingChannelAdapter adapter = new RecordingChannelAdapter(); - registry.register(adapter); - - ConversationAgent conversationAgent = (request, progressConsumer) -> { - String message = request.getCurrentMessage(); - if ("question-parent-nested".equals(message)) { - request.getSpawnTaskSupport().spawnTask("child-needs-more"); - return "parent-waiting"; - } - if ("child-needs-more".equals(message)) { - try { - request.getSpawnTaskSupport().spawnTask("nested-child"); - return "nested-allowed"; - } catch (IllegalStateException e) { - return "child-blocked:" + e.getMessage(); - } - } - if (message != null && message.contains("[内部事件] 子任务已完成")) { - return message.contains("当前子任务默认禁止继续派生子任务") - ? "parent-saw-nested-block" - : "parent-missed-nested-block"; - } - return "reply-" + message; - }; - - SolonClawProperties properties = new SolonClawProperties(); - properties.getAgent().getScheduler().setMaxConcurrentPerConversation(1); - properties.getAgent().getScheduler().setAckWhenBusy(false); - properties.getAgent().getSubtasks().setAllowNestedSpawn(false); - - try { - AgentRuntimeService runtimeService = new AgentRuntimeService(conversationAgent, store, scheduler, registry, properties); - String parentRunId = runtimeService.submitInbound(inbound("msg-parent-nested", "question-parent-nested")); - assertNotNull(parentRunId); - - assertTrue(waitUntil(() -> adapter.messages.contains("parent-saw-nested-block"), 5000)); - assertEquals(1, runtimeService.listChildRuns(parentRunId, null).size()); - assertTrue(store.getRunEvents(runtimeService.listChildRuns(parentRunId, null).get(0).getRunId(), 0).stream() - .anyMatch(event -> "spawn_task_blocked".equals(event.getEventType()))); - } finally { - scheduler.shutdown(); - } - } - - /** - * 验证后续一句“看看上个任务的情况”可以通过查询能力读取最近子任务状态。 - * - * @throws Exception 执行异常 - */ - @Test - void followupMessageCanInspectLatestChildRunStatus() throws Exception { - RuntimeStoreService store = new RuntimeStoreService(tempDir.toFile()); - ConversationScheduler scheduler = new ConversationScheduler(1); - ChannelRegistry registry = new ChannelRegistry(); - RecordingChannelAdapter adapter = new RecordingChannelAdapter(); - registry.register(adapter); - - ConversationAgent conversationAgent = (request, progressConsumer) -> { - String message = request.getCurrentMessage(); - if ("question-parent".equals(message)) { - request.getSpawnTaskSupport().spawnTask("research-child"); - return "parent-waiting"; - } - if ("research-child".equals(message)) { - return "child-result"; - } - if (message != null && message.contains("[内部事件] 子任务已完成")) { - return "child-finished"; - } - if ("看看上个任务的情况".equals(message)) { - AgentRun latestChild = request.getRunQuerySupport().getLatestChildRun(); - return "latest-child-status=" + (latestChild == null ? "NONE" : latestChild.getStatus()); - } - return "reply-" + message; - }; - - SolonClawProperties properties = new SolonClawProperties(); - properties.getAgent().getScheduler().setMaxConcurrentPerConversation(1); - properties.getAgent().getScheduler().setAckWhenBusy(false); - - try { - AgentRuntimeService runtimeService = new AgentRuntimeService(conversationAgent, store, scheduler, registry, properties); - runtimeService.submitInbound(inbound("msg-parent", "question-parent")); - assertTrue(waitUntil(() -> adapter.messages.contains("child-finished"), 5000)); - - String inspectRunId = runtimeService.submitInbound(inbound("msg-inspect", "看看上个任务的情况")); - assertNotNull(inspectRunId); - assertTrue(waitUntil(() -> adapter.messages.contains("latest-child-status=SUCCEEDED"), 5000)); - } finally { - scheduler.shutdown(); - } - } - - /** - * 验证子任务完成后,父运行会记录更清晰的调试事件,并向 continuation 注入结构化汇总。 - * - * @throws Exception 执行异常 - */ - @Test - void childCompletionAddsParentDebugEventsAndStructuredSummary() throws Exception { - RuntimeStoreService store = new RuntimeStoreService(tempDir.toFile()); - ConversationScheduler scheduler = new ConversationScheduler(1); - ChannelRegistry registry = new ChannelRegistry(); - RecordingChannelAdapter adapter = new RecordingChannelAdapter(); - registry.register(adapter); - - ConversationAgent conversationAgent = (request, progressConsumer) -> { - String message = request.getCurrentMessage(); - if ("question-parent-summary".equals(message)) { - request.getSpawnTaskSupport().spawnTask("summary-child"); - return "parent-waiting"; - } - if ("summary-child".equals(message)) { - return "summary-child-result"; - } - if (message != null && message.contains("[内部事件] 子任务已完成")) { - return message.contains("全部子任务汇总: total=1") - && message.contains("FINAL_REPLY_ONCE:") - && message.contains("NO_REPLY") - ? "parent-saw-summary-guidance" - : "parent-missed-summary-guidance"; - } - return "reply-" + message; - }; - - SolonClawProperties properties = new SolonClawProperties(); - properties.getAgent().getScheduler().setMaxConcurrentPerConversation(1); - properties.getAgent().getScheduler().setAckWhenBusy(false); - - try { - AgentRuntimeService runtimeService = new AgentRuntimeService(conversationAgent, store, scheduler, registry, properties); - String parentRunId = runtimeService.submitInbound(inbound("msg-parent-summary", "question-parent-summary")); - assertNotNull(parentRunId); - - assertTrue(waitUntil(() -> adapter.messages.contains("parent-saw-summary-guidance"), 5000)); - - List eventTypes = store.getRunEvents(parentRunId, 0).stream() - .map(event -> event.getEventType()) - .collect(java.util.stream.Collectors.toList()); - assertTrue(eventTypes.contains("child_completion_received")); - assertTrue(eventTypes.contains("children_all_completed")); - assertTrue(eventTypes.contains("child_continuation_submitted")); - - List events = store.readConversationEvents("dingtalk:group:group-1"); - assertTrue(events.stream().anyMatch(event -> - "system_event".equals(event.getEventType()) - && event.getContent() != null - && event.getContent().contains("全部子任务汇总: total=1") - && event.getContent().contains("FINAL_REPLY_ONCE:") - && event.getContent().contains("NO_REPLY"))); - } finally { - scheduler.shutdown(); - } - } - - /** - * 验证当前运行可通过主动通知能力直接向当前会话用户发消息。 - * - * @throws Exception 执行异常 - */ @Test void runCanNotifyUserProactively() throws Exception { RuntimeStoreService store = new RuntimeStoreService(tempDir.toFile()); @@ -336,7 +97,6 @@ class AgentRuntimeServiceTest { ChannelRegistry registry = new ChannelRegistry(); RecordingChannelAdapter adapter = new RecordingChannelAdapter(); registry.register(adapter); - ConversationAgent conversationAgent = (request, progressConsumer) -> { if ("请主动通知我".equals(request.getCurrentMessage())) { NotificationResult result = request.getNotificationSupport().notifyUser("这是一条主动通知", false); @@ -345,399 +105,87 @@ class AgentRuntimeServiceTest { } return "reply-" + request.getCurrentMessage(); }; - SolonClawProperties properties = new SolonClawProperties(); - properties.getAgent().getScheduler().setMaxConcurrentPerConversation(1); - properties.getAgent().getScheduler().setAckWhenBusy(false); + AgentRuntimeService runtimeService = runtimeService(conversationAgent, store, scheduler, registry, properties); try { - AgentRuntimeService runtimeService = new AgentRuntimeService(conversationAgent, store, scheduler, registry, properties); String runId = runtimeService.submitInbound(inbound("msg-notify", "请主动通知我")); assertNotNull(runId); assertTrue(waitUntil(() -> adapter.messages.contains("这是一条主动通知"), 5000)); assertEquals(1, adapter.outbounds.size()); - assertEquals("这是一条主动通知", adapter.outbounds.get(0).getContent()); - } finally { - scheduler.shutdown(); - } - } - - /** - * 验证静默系统触发可主动通知用户,但不会把最终回答再次作为普通回复对外发送。 - * - * @throws Exception 执行异常 - */ - @Test - void silentSystemMessageDoesNotDoubleSendAfterNotifyUser() throws Exception { - RuntimeStoreService store = new RuntimeStoreService(tempDir.toFile()); - ConversationScheduler scheduler = new ConversationScheduler(1); - ChannelRegistry registry = new ChannelRegistry(); - RecordingChannelAdapter adapter = new RecordingChannelAdapter(); - registry.register(adapter); - - ConversationAgent conversationAgent = (request, progressConsumer) -> { - assertEquals(InboundTriggerType.SYSTEM_SILENT, request.getCurrentMessageTriggerType()); - NotificationResult result = request.getNotificationSupport().notifyUser("静默提醒", false); - assertTrue(result.isDelivered()); - return "已发送提醒"; - }; - - SolonClawProperties properties = new SolonClawProperties(); - properties.getAgent().getScheduler().setMaxConcurrentPerConversation(1); - properties.getAgent().getScheduler().setAckWhenBusy(false); - - try { - AgentRuntimeService runtimeService = new AgentRuntimeService(conversationAgent, store, scheduler, registry, properties); - ReplyTarget replyTarget = new ReplyTarget(ChannelType.DINGTALK, ConversationType.GROUP, "group-1", "user-1"); - store.rememberReplyTarget("dingtalk:group:group-1", replyTarget); - - String runId = runtimeService.submitSilentSystemMessage("dingtalk:group:group-1", replyTarget, "内部提醒"); - assertNotNull(runId); - assertTrue(waitUntil(() -> adapter.messages.contains("静默提醒"), 5000)); - - assertEquals(1, adapter.outbounds.size()); - assertEquals("静默提醒", adapter.outbounds.get(0).getContent()); - assertTrue(store.readConversationEvents("dingtalk:group:group-1").isEmpty()); - } finally { - scheduler.shutdown(); - } - } - - /** - * 验证定时提醒类的静默系统触发在未显式 notify_user 时,会把面向用户的提醒文案兜底发送一次。 - * - * @throws Exception 执行异常 - */ - @Test - void silentReminderCanFallbackToSingleOutboundDelivery() throws Exception { - RuntimeStoreService store = new RuntimeStoreService(tempDir.toFile()); - ConversationScheduler scheduler = new ConversationScheduler(1); - ChannelRegistry registry = new ChannelRegistry(); - RecordingChannelAdapter adapter = new RecordingChannelAdapter(); - registry.register(adapter); - - ConversationAgent conversationAgent = (request, progressConsumer) -> { - assertEquals(InboundTriggerType.SYSTEM_SILENT, request.getCurrentMessageTriggerType()); - return "活动一下胳膊吧,顺便转转脖子,休息一下眼睛。"; - }; - - SolonClawProperties properties = new SolonClawProperties(); - properties.getAgent().getScheduler().setMaxConcurrentPerConversation(1); - properties.getAgent().getScheduler().setAckWhenBusy(false); - - try { - AgentRuntimeService runtimeService = new AgentRuntimeService(conversationAgent, store, scheduler, registry, properties); - ReplyTarget replyTarget = new ReplyTarget(ChannelType.DINGTALK, ConversationType.GROUP, "group-1", "user-1"); - store.rememberReplyTarget("dingtalk:group:group-1", replyTarget); - - String content = "[内部定时任务触发]\n" - + "任务名称: 活动一下\n" - + "提醒内容:\n提醒用户活动一下胳膊,伸展一下"; - String runId = runtimeService.submitSilentSystemMessage("dingtalk:group:group-1", replyTarget, content); - assertNotNull(runId); - assertTrue(waitUntil(() -> adapter.messages.contains("活动一下胳膊吧,顺便转转脖子,休息一下眼睛。"), 5000)); - - assertEquals(1, adapter.outbounds.size()); - assertEquals("活动一下胳膊吧,顺便转转脖子,休息一下眼睛。", adapter.outbounds.get(0).getContent()); - assertTrue(store.hasRunEventType(runId, "silent_notify_fallback")); } finally { scheduler.shutdown(); } } - /** - * 验证可按父运行聚合多个子任务,并判断是否全部完成。 - * - * @throws Exception 执行异常 - */ @Test - void followupMessageCanInspectParentRunChildSummary() throws Exception { + void childRunCompletionUsesSystemEventRunnerForAggregateReply() throws Exception { RuntimeStoreService store = new RuntimeStoreService(tempDir.toFile()); ConversationScheduler scheduler = new ConversationScheduler(1); ChannelRegistry registry = new ChannelRegistry(); RecordingChannelAdapter adapter = new RecordingChannelAdapter(); registry.register(adapter); - CountDownLatch slowChildStarted = new CountDownLatch(1); - CountDownLatch releaseSlowChild = new CountDownLatch(1); - ConversationAgent conversationAgent = (request, progressConsumer) -> { String message = request.getCurrentMessage(); - if ("question-parent-multi".equals(message)) { - request.getSpawnTaskSupport().spawnTask("child-fast-1"); - request.getSpawnTaskSupport().spawnTask("child-fast-2"); - request.getSpawnTaskSupport().spawnTask("child-slow-3"); - return "parent-waiting-multi"; - } - if ("child-fast-1".equals(message) || "child-fast-2".equals(message)) { - return "done-" + message; - } - if ("child-slow-3".equals(message)) { - slowChildStarted.countDown(); - assertTrue(releaseSlowChild.await(5, TimeUnit.SECONDS)); - return "done-" + message; - } - if (message != null && message.contains("[内部事件] 子任务已完成")) { - return "child-finished"; - } - if ("看看这批子任务是否都完成了".equals(message)) { - ParentRunChildrenSummary summary = request.getRunQuerySupport().getChildSummary(null, null); - if (summary == null) { - return "summary-missing"; - } - return "summary total=" + summary.getTotalChildren() - + " pending=" + summary.getPendingChildren() - + " allCompleted=" + summary.isAllCompleted(); - } - return "reply-" + message; - }; - - SolonClawProperties properties = new SolonClawProperties(); - properties.getAgent().getScheduler().setMaxConcurrentPerConversation(1); - properties.getAgent().getScheduler().setAckWhenBusy(false); - - try { - AgentRuntimeService runtimeService = new AgentRuntimeService(conversationAgent, store, scheduler, registry, properties); - String parentRunId = runtimeService.submitInbound(inbound("msg-parent-multi", "question-parent-multi")); - assertNotNull(parentRunId); - assertTrue(slowChildStarted.await(3, TimeUnit.SECONDS)); - - String inspectPendingRunId = runtimeService.submitInbound(inbound("msg-check-pending", "看看这批子任务是否都完成了")); - assertNotNull(inspectPendingRunId); - assertTrue(waitUntil(() -> adapter.messages.stream().anyMatch(message -> - message.contains("summary total=3 pending=1 allCompleted=false")), 5000)); - - releaseSlowChild.countDown(); - assertTrue(waitUntil(() -> adapter.messages.stream().filter("child-finished"::equals).count() >= 3, 5000)); - - String inspectDoneRunId = runtimeService.submitInbound(inbound("msg-check-done", "看看这批子任务是否都完成了")); - assertNotNull(inspectDoneRunId); - assertTrue(waitUntil(() -> adapter.messages.stream().anyMatch(message -> - message.contains("summary total=3 pending=0 allCompleted=true")), 5000)); - } finally { - releaseSlowChild.countDown(); - scheduler.shutdown(); - } - } - - /** - * 验证父会话可在子任务未全部完成时返回 NO_REPLY,待全部完成后再统一汇总回复。 - * - * @throws Exception 执行异常 - */ - @Test - void parentCanSuppressIntermediateRepliesUntilAllChildrenComplete() throws Exception { - RuntimeStoreService store = new RuntimeStoreService(tempDir.toFile()); - ConversationScheduler scheduler = new ConversationScheduler(1); - ChannelRegistry registry = new ChannelRegistry(); - RecordingChannelAdapter adapter = new RecordingChannelAdapter(); - registry.register(adapter); - - CountDownLatch slowChildStarted = new CountDownLatch(1); - CountDownLatch releaseSlowChild = new CountDownLatch(1); - - ConversationAgent conversationAgent = (request, progressConsumer) -> { - String message = request.getCurrentMessage(); - if ("question-parent-aggregate".equals(message)) { - request.getSpawnTaskSupport().spawnTask("aggregate-fast-1"); - request.getSpawnTaskSupport().spawnTask("aggregate-fast-2"); - request.getSpawnTaskSupport().spawnTask("aggregate-slow-3"); - return "parent-aggregate-waiting"; - } - if ("aggregate-fast-1".equals(message) || "aggregate-fast-2".equals(message)) { - return "done-" + message; + if (request.getCurrentSourceKind() == RuntimeSourceKind.USER_MESSAGE && "question-parent".equals(message)) { + request.getSpawnTaskSupport().spawnTask("research-child"); + return "parent-waiting"; } - if ("aggregate-slow-3".equals(message)) { - slowChildStarted.countDown(); - assertTrue(releaseSlowChild.await(5, TimeUnit.SECONDS)); - return "done-" + message; + if (request.isChildRun() && "research-child".equals(message)) { + return "child-result"; } - if (message != null && message.contains("[内部事件] 子任务已完成")) { - ParentRunChildrenSummary summary = request.getRunQuerySupport().getChildSummary(null, null); - if (summary == null || !summary.isAllCompleted()) { - return AgentRuntimeService.NO_REPLY; - } - return AgentRuntimeService.FINAL_REPLY_ONCE_PREFIX - + "final-aggregate total=" + summary.getTotalChildren() - + " succeeded=" + summary.getSucceededChildren() - + " failed=" + summary.getFailedChildren(); + if (request.getCurrentSourceKind() == RuntimeSourceKind.CHILD_CONTINUATION) { + return AgentRuntimeService.FINAL_REPLY_ONCE_PREFIX + "final-parent-answer"; } return "reply-" + message; }; - SolonClawProperties properties = new SolonClawProperties(); - properties.getAgent().getScheduler().setMaxConcurrentPerConversation(1); - properties.getAgent().getScheduler().setAckWhenBusy(false); + AgentRuntimeService runtimeService = runtimeService(conversationAgent, store, scheduler, registry, properties); try { - AgentRuntimeService runtimeService = new AgentRuntimeService(conversationAgent, store, scheduler, registry, properties); - String parentRunId = runtimeService.submitInbound(inbound("msg-parent-aggregate", "question-parent-aggregate")); + String parentRunId = runtimeService.submitInbound(inbound("msg-parent", "question-parent")); assertNotNull(parentRunId); - assertTrue(slowChildStarted.await(3, TimeUnit.SECONDS)); - - assertTrue(waitUntil(() -> runtimeService.getRun(parentRunId).getStatus() == RunStatus.WAITING_CHILDREN, 3000)); - assertTrue(waitUntil(() -> adapter.outbounds.isEmpty(), 1000)); - - releaseSlowChild.countDown(); - assertTrue(waitUntil(() -> adapter.messages.stream().anyMatch(message -> - message.contains("final-aggregate total=3 succeeded=3 failed=0")), 5000)); + assertTrue(waitUntil(() -> adapter.messages.contains("final-parent-answer"), 5000)); assertEquals(1, adapter.outbounds.stream() - .filter(outbound -> outbound.getContent().contains("final-aggregate total=3 succeeded=3 failed=0")) + .filter(outbound -> "final-parent-answer".equals(outbound.getContent())) .count()); - } finally { - releaseSlowChild.countDown(); - scheduler.shutdown(); - } - } - - /** - * 验证同一父运行下可按 batchKey 查询指定批次的子任务聚合结果。 - * - * @throws Exception 执行异常 - */ - @Test - void followupMessageCanInspectChildSummaryByBatchKey() throws Exception { - RuntimeStoreService store = new RuntimeStoreService(tempDir.toFile()); - ConversationScheduler scheduler = new ConversationScheduler(1); - ChannelRegistry registry = new ChannelRegistry(); - RecordingChannelAdapter adapter = new RecordingChannelAdapter(); - registry.register(adapter); - - CountDownLatch slowBatchStarted = new CountDownLatch(1); - CountDownLatch releaseSlowBatch = new CountDownLatch(1); - - ConversationAgent conversationAgent = (request, progressConsumer) -> { - String message = request.getCurrentMessage(); - if ("question-parent-batch".equals(message)) { - request.getSpawnTaskSupport().spawnTask("batch-A-fast", "plan-A"); - request.getSpawnTaskSupport().spawnTask("batch-A-slow", "plan-A"); - request.getSpawnTaskSupport().spawnTask("batch-B-fast", "plan-B"); - return "batch-waiting"; - } - if ("batch-A-fast".equals(message) || "batch-B-fast".equals(message)) { - return "done-" + message; - } - if ("batch-A-slow".equals(message)) { - slowBatchStarted.countDown(); - assertTrue(releaseSlowBatch.await(5, TimeUnit.SECONDS)); - return "done-" + message; - } - if (message != null && message.contains("[内部事件] 子任务已完成")) { - return AgentRuntimeService.NO_REPLY; - } - if ("看看 plan-A 这批任务的情况".equals(message)) { - ParentRunChildrenSummary summary = request.getRunQuerySupport().getChildSummary(null, "plan-A"); - if (summary == null) { - return "plan-A-missing"; - } - return "plan-A total=" + summary.getTotalChildren() - + " pending=" + summary.getPendingChildren() - + " allCompleted=" + summary.isAllCompleted(); - } - return "reply-" + message; - }; - - SolonClawProperties properties = new SolonClawProperties(); - properties.getAgent().getScheduler().setMaxConcurrentPerConversation(1); - properties.getAgent().getScheduler().setAckWhenBusy(false); - - try { - AgentRuntimeService runtimeService = new AgentRuntimeService(conversationAgent, store, scheduler, registry, properties); - String parentRunId = runtimeService.submitInbound(inbound("msg-parent-batch", "question-parent-batch")); - assertNotNull(parentRunId); - assertTrue(slowBatchStarted.await(3, TimeUnit.SECONDS)); - - String inspectPendingRunId = runtimeService.submitInbound(inbound("msg-planA-pending", "看看 plan-A 这批任务的情况")); - assertNotNull(inspectPendingRunId); - assertTrue(waitUntil(() -> adapter.messages.stream().anyMatch(message -> - message.contains("plan-A total=2 pending=1 allCompleted=false")), 5000)); - - releaseSlowBatch.countDown(); - String inspectDoneRunId = runtimeService.submitInbound(inbound("msg-planA-done", "看看 plan-A 这批任务的情况")); - assertNotNull(inspectDoneRunId); - assertTrue(waitUntil(() -> adapter.messages.stream().anyMatch(message -> - message.contains("plan-A total=2 pending=0 allCompleted=true")), 5000)); - } finally { - releaseSlowBatch.countDown(); - scheduler.shutdown(); - } - } - - /** - * 验证系统消息不会覆盖最近一次真实外部会话路由。 - */ - @Test - void systemMessageDoesNotOverrideLatestExternalRoute() throws Exception { - RuntimeStoreService store = new RuntimeStoreService(tempDir.toFile()); - ConversationScheduler scheduler = new ConversationScheduler(1); - ChannelRegistry registry = new ChannelRegistry(); - RecordingChannelAdapter adapter = new RecordingChannelAdapter(); - registry.register(adapter); - - ConversationAgent conversationAgent = (request, progressConsumer) -> "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-latest", "question-latest")); - - ReplyTarget otherReplyTarget = new ReplyTarget(ChannelType.DINGTALK, ConversationType.GROUP, "group-2", "user-2"); - runtimeService.submitSystemMessage("dingtalk:group:group-2", otherReplyTarget, "scheduled-message"); - - assertEquals("dingtalk:group:group-1", store.getLatestExternalRoute().getSessionKey()); - assertEquals("group-1", store.getLatestExternalRoute().getReplyTarget().getConversationId()); - assertTrue(waitUntil(() -> adapter.outbounds.size() >= 2, 2000)); + assertTrue(store.hasRunEventType(parentRunId, "children_aggregated")); + assertTrue(store.getRunEvents(parentRunId, 0).stream() + .map(RunEvent::getEventType) + .anyMatch("child_continuation_triggered"::equals)); } finally { scheduler.shutdown(); } } - /** - * 验证可见系统触发不会被写成用户消息,并会带着系统触发类型进入执行层。 - */ @Test - void visibleSystemMessageIsNotPersistedAsUserMessage() throws Exception { + void debugMessageIsStillUserMessageAndDoesNotRememberExternalRoute() 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(); + return "debug-reply"; }; - SolonClawProperties properties = new SolonClawProperties(); - properties.getAgent().getScheduler().setAckWhenBusy(false); + AgentRuntimeService runtimeService = runtimeService(conversationAgent, store, scheduler, registry, properties); 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()); + String runId = runtimeService.submitDebugMessage("debug-1", "hello"); + assertNotNull(runId); + assertTrue(waitUntil(() -> { + AgentRun run = runtimeService.getRun(runId); + return run != null && run.getStatus() == RunStatus.SUCCEEDED; + }, 5000)); + assertEquals(RuntimeSourceKind.USER_MESSAGE, lastRequest.get().getCurrentSourceKind()); + assertNull(store.getLatestExternalRoute()); } finally { scheduler.shutdown(); } } - /** - * 验证只有声明支持进度更新的渠道才会收到运行中的增量内容。 - */ @Test void progressIsDispatchedOnlyToProgressCapableChannel() throws Exception { RuntimeStoreService store = new RuntimeStoreService(tempDir.toFile()); @@ -753,10 +201,9 @@ class AgentRuntimeServiceTest { }; SolonClawProperties properties = new SolonClawProperties(); - properties.getAgent().getScheduler().setAckWhenBusy(false); + AgentRuntimeService runtimeService = runtimeService(conversationAgent, store, scheduler, registry, properties); try { - AgentRuntimeService runtimeService = new AgentRuntimeService(conversationAgent, store, scheduler, registry, properties); InboundEnvelope inboundEnvelope = new InboundEnvelope(); inboundEnvelope.setMessageId("msg-feishu"); inboundEnvelope.setChannelType(ChannelType.FEISHU); @@ -768,6 +215,7 @@ class AgentRuntimeServiceTest { inboundEnvelope.setReplyTarget(new ReplyTarget(ChannelType.FEISHU, ConversationType.GROUP, "oc-1", "ou-1")); inboundEnvelope.setReceivedAt(System.currentTimeMillis()); inboundEnvelope.setSessionKey("feishu:group:oc-1"); + inboundEnvelope.setSourceKind(RuntimeSourceKind.USER_MESSAGE); runtimeService.submitInbound(inboundEnvelope); @@ -780,13 +228,17 @@ class AgentRuntimeServiceTest { } } - /** - * 构造一条测试入站消息。 - * - * @param messageId 消息标识 - * @param content 文本内容 - * @return 入站消息 - */ + private AgentRuntimeService runtimeService( + ConversationAgent conversationAgent, + RuntimeStoreService store, + ConversationScheduler scheduler, + ChannelRegistry registry, + SolonClawProperties properties + ) { + SystemEventRunner systemEventRunner = new SystemEventRunner(conversationAgent, store, scheduler, registry, properties); + return new AgentRuntimeService(conversationAgent, store, scheduler, registry, systemEventRunner, properties); + } + private InboundEnvelope inbound(String messageId, String content) { ReplyTarget replyTarget = new ReplyTarget(ChannelType.DINGTALK, ConversationType.GROUP, "group-1", "user-1"); @@ -801,17 +253,10 @@ class AgentRuntimeServiceTest { envelope.setReplyTarget(replyTarget); envelope.setReceivedAt(System.currentTimeMillis()); envelope.setSessionKey("dingtalk:group:group-1"); + envelope.setSourceKind(RuntimeSourceKind.USER_MESSAGE); return envelope; } - /** - * 轮询等待条件成立。 - * - * @param condition 条件判断 - * @param timeoutMs 超时时间 - * @return 若条件成立则返回 true - * @throws InterruptedException 线程中断异常 - */ private boolean waitUntil(BooleanSupplier condition, long timeoutMs) throws InterruptedException { long deadline = System.currentTimeMillis() + timeoutMs; while (System.currentTimeMillis() < deadline) { @@ -823,30 +268,15 @@ class AgentRuntimeServiceTest { return condition.getAsBoolean(); } - /** - * 记录测试发送内容的伪渠道适配器。 - */ private static class RecordingChannelAdapter implements ChannelAdapter { - /** 记录发送文本。 */ - protected final List messages = new CopyOnWriteArrayList<>(); - /** 记录完整出站消息。 */ - protected final List outbounds = new CopyOnWriteArrayList<>(); - - /** - * 返回适配器渠道类型。 - * - * @return 钉钉渠道 - */ + protected final List messages = new CopyOnWriteArrayList(); + protected final List outbounds = new CopyOnWriteArrayList(); + @Override public ChannelType channelType() { return ChannelType.DINGTALK; } - /** - * 记录一次发送请求。 - * - * @param outboundEnvelope 出站消息 - */ @Override public void send(OutboundEnvelope outboundEnvelope) { outbounds.add(outboundEnvelope); @@ -854,9 +284,6 @@ class AgentRuntimeServiceTest { } } - /** - * 支持进度更新的伪渠道适配器。 - */ private static class ProgressChannelAdapter extends RecordingChannelAdapter { @Override public ChannelType channelType() { @@ -868,6 +295,28 @@ class AgentRuntimeServiceTest { return true; } } -} + @Test + void emptyModelResponseFallsBackToUserFriendlyFailureReply() throws Exception { + RuntimeStoreService store = new RuntimeStoreService(tempDir.toFile()); + ConversationScheduler scheduler = new ConversationScheduler(1); + ChannelRegistry registry = new ChannelRegistry(); + RecordingChannelAdapter adapter = new RecordingChannelAdapter(); + registry.register(adapter); + + ConversationAgent conversationAgent = (request, progressConsumer) -> ""; + SolonClawProperties properties = new SolonClawProperties(); + AgentRuntimeService runtimeService = runtimeService(conversationAgent, store, scheduler, registry, properties); + try { + String runId = runtimeService.submitInbound(inbound("msg-empty", "空回复测试")); + assertNotNull(runId); + assertTrue(waitUntil(() -> !adapter.messages.isEmpty(), 5000)); + assertEquals("这次处理没有拿到有效结果,可能是模型响应超时或解析异常。请再试一次。", adapter.messages.get(0)); + assertEquals(RunStatus.FAILED, runtimeService.getRun(runId).getStatus()); + assertTrue(store.hasRunEventType(runId, "llm_empty_response")); + } finally { + scheduler.shutdown(); + } + } +} diff --git a/src/test/java/com/jimuqu/claw/agent/runtime/HeartbeatServiceTest.java b/src/test/java/com/jimuqu/claw/agent/runtime/HeartbeatServiceTest.java index 2e3f01b..01a101c 100644 --- a/src/test/java/com/jimuqu/claw/agent/runtime/HeartbeatServiceTest.java +++ b/src/test/java/com/jimuqu/claw/agent/runtime/HeartbeatServiceTest.java @@ -1,45 +1,34 @@ package com.jimuqu.claw.agent.runtime; import cn.hutool.core.io.FileUtil; -import com.jimuqu.claw.agent.channel.ChannelAdapter; import com.jimuqu.claw.agent.channel.ChannelRegistry; import com.jimuqu.claw.agent.model.enums.ChannelType; import com.jimuqu.claw.agent.model.enums.ConversationType; -import com.jimuqu.claw.agent.model.envelope.OutboundEnvelope; +import com.jimuqu.claw.agent.model.enums.RuntimeSourceKind; +import com.jimuqu.claw.agent.model.enums.SystemEventPolicy; import com.jimuqu.claw.agent.model.route.ReplyTarget; import com.jimuqu.claw.agent.runtime.api.ConversationAgent; -import com.jimuqu.claw.agent.runtime.impl.AgentRuntimeService; import com.jimuqu.claw.agent.runtime.impl.ConversationScheduler; import com.jimuqu.claw.agent.runtime.impl.HeartbeatService; +import com.jimuqu.claw.agent.runtime.impl.SystemEventRunner; +import com.jimuqu.claw.agent.runtime.support.SystemEventRequest; import com.jimuqu.claw.agent.store.RuntimeStoreService; import com.jimuqu.claw.config.SolonClawProperties; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import java.nio.file.Path; -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 static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertNotNull; -/** - * 验证心跳服务只会触发静默内部运行,不会直接向外部渠道发送消息。 - */ class HeartbeatServiceTest { - /** 临时测试目录。 */ @TempDir Path tempDir; - /** - * 验证一次心跳轮询会触发静默内部运行。 - * - * @throws Exception 执行异常 - */ @Test - void tickRunsHeartbeatSilentlyWithoutOutboundReply() throws Exception { + void tickSubmitsHeartbeatEventToSystemEventRunner() { Path workspace = tempDir.resolve("workspace"); FileUtil.mkdir(workspace.toFile()); FileUtil.writeUtf8String("请汇报当前状态", workspace.resolve("HEARTBEAT.md").toFile()); @@ -48,60 +37,44 @@ class HeartbeatServiceTest { ReplyTarget replyTarget = new ReplyTarget(ChannelType.DINGTALK, ConversationType.GROUP, "group-9", "user-9"); store.rememberReplyTarget("dingtalk:group:group-9", replyTarget); - CountDownLatch executed = new CountDownLatch(1); - ConversationAgent conversationAgent = (request, progressConsumer) -> { - executed.countDown(); - return "heartbeat:" + request.getCurrentMessage(); - }; - ConversationScheduler scheduler = new ConversationScheduler(1); - ChannelRegistry registry = new ChannelRegistry(); - RecordingChannelAdapter adapter = new RecordingChannelAdapter(); - registry.register(adapter); - SolonClawProperties properties = new SolonClawProperties(); properties.setWorkspace(workspace.toString()); - try { - AgentRuntimeService runtimeService = new AgentRuntimeService(conversationAgent, store, scheduler, registry, properties); - HeartbeatService heartbeatService = new HeartbeatService(runtimeService, store, properties); + ConversationScheduler scheduler = new ConversationScheduler(1); + CapturingSystemEventRunner runner = new CapturingSystemEventRunner(store, scheduler, properties); + HeartbeatService heartbeatService = new HeartbeatService(runner, store, properties); - heartbeatService.tick(); + heartbeatService.tick(); - assertTrue(executed.await(3, TimeUnit.SECONDS)); - Thread.sleep(200); - assertTrue(adapter.messages.isEmpty()); - assertEquals(0, store.readConversationEvents("dingtalk:group:group-9").size()); - } finally { - scheduler.shutdown(); - } + SystemEventRequest request = runner.lastRequest.get(); + assertNotNull(request); + assertEquals(RuntimeSourceKind.HEARTBEAT_EVENT, request.getSourceKind()); + assertEquals(SystemEventPolicy.INTERNAL_ONLY, request.getPolicy()); + assertEquals("请汇报当前状态", request.getContent()); + assertEquals("dingtalk:group:group-9", request.getSessionKey()); } - /** - * 记录测试发送消息的伪渠道适配器。 - */ - private static class RecordingChannelAdapter implements ChannelAdapter { - /** 收到的消息列表。 */ - private final List messages = new CopyOnWriteArrayList<>(); + private static class CapturingSystemEventRunner extends SystemEventRunner { + private final AtomicReference lastRequest = new AtomicReference(); - /** - * 返回适配器渠道类型。 - * - * @return 钉钉渠道 - */ - @Override - public ChannelType channelType() { - return ChannelType.DINGTALK; + private CapturingSystemEventRunner( + RuntimeStoreService runtimeStoreService, + ConversationScheduler conversationScheduler, + SolonClawProperties properties + ) { + super( + (ConversationAgent) (request, progressConsumer) -> "noop", + runtimeStoreService, + conversationScheduler, + new ChannelRegistry(), + properties + ); } - /** - * 记录发送内容。 - * - * @param outboundEnvelope 出站消息 - */ @Override - public void send(OutboundEnvelope outboundEnvelope) { - messages.add(outboundEnvelope.getContent()); + public String submit(SystemEventRequest request) { + lastRequest.set(request); + return "captured-heartbeat"; } } } - diff --git a/src/test/java/com/jimuqu/claw/agent/runtime/IsolatedAgentRunServiceTest.java b/src/test/java/com/jimuqu/claw/agent/runtime/IsolatedAgentRunServiceTest.java new file mode 100644 index 0000000..5a91fa1 --- /dev/null +++ b/src/test/java/com/jimuqu/claw/agent/runtime/IsolatedAgentRunServiceTest.java @@ -0,0 +1,144 @@ +package com.jimuqu.claw.agent.runtime; + +import com.jimuqu.claw.agent.channel.ChannelAdapter; +import com.jimuqu.claw.agent.channel.ChannelRegistry; +import com.jimuqu.claw.agent.job.AgentTurnSpec; +import com.jimuqu.claw.agent.job.JobDeliveryMode; +import com.jimuqu.claw.agent.model.envelope.OutboundEnvelope; +import com.jimuqu.claw.agent.model.enums.ChannelType; +import com.jimuqu.claw.agent.model.enums.ConversationType; +import com.jimuqu.claw.agent.model.enums.RunStatus; +import com.jimuqu.claw.agent.model.route.ReplyTarget; +import com.jimuqu.claw.agent.model.run.AgentRun; +import com.jimuqu.claw.agent.runtime.api.ConversationAgent; +import com.jimuqu.claw.agent.runtime.impl.ConversationScheduler; +import com.jimuqu.claw.agent.runtime.impl.IsolatedAgentRunService; +import com.jimuqu.claw.agent.runtime.support.AgentTurnRequest; +import com.jimuqu.claw.agent.store.RuntimeStoreService; +import com.jimuqu.claw.config.SolonClawProperties; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Path; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.BooleanSupplier; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class IsolatedAgentRunServiceTest { + @TempDir + Path tempDir; + + @Test + void deliveryNoneSuppressesFinalReply() throws Exception { + RuntimeStoreService store = new RuntimeStoreService(tempDir.toFile()); + ConversationScheduler scheduler = new ConversationScheduler(1); + ChannelRegistry registry = new ChannelRegistry(); + RecordingChannelAdapter adapter = new RecordingChannelAdapter(); + registry.register(adapter); + SolonClawProperties properties = new SolonClawProperties(); + ConversationAgent conversationAgent = (request, progressConsumer) -> "自动化结果"; + IsolatedAgentRunService service = new IsolatedAgentRunService(conversationAgent, store, scheduler, registry, properties); + + try { + String runId = service.submit(request(JobDeliveryMode.NONE, null)); + assertNotNull(runId); + assertTrue(waitUntil(() -> { + AgentRun run = store.getRun(runId); + return run != null && run.getStatus() == RunStatus.SUCCEEDED; + }, 5000)); + assertTrue(adapter.outbounds.isEmpty()); + assertTrue(store.hasRunEventType(runId, "delivery_suppressed")); + } finally { + scheduler.shutdown(); + } + } + + @Test + void boundReplyTargetDeliverySendsFinalReply() throws Exception { + RuntimeStoreService store = new RuntimeStoreService(tempDir.toFile()); + ConversationScheduler scheduler = new ConversationScheduler(1); + ChannelRegistry registry = new ChannelRegistry(); + RecordingChannelAdapter adapter = new RecordingChannelAdapter(); + registry.register(adapter); + SolonClawProperties properties = new SolonClawProperties(); + ConversationAgent conversationAgent = (request, progressConsumer) -> "自动化结果"; + IsolatedAgentRunService service = new IsolatedAgentRunService(conversationAgent, store, scheduler, registry, properties); + + try { + ReplyTarget boundReplyTarget = new ReplyTarget(ChannelType.DINGTALK, ConversationType.GROUP, "group-1", "user-1"); + String runId = service.submit(request(JobDeliveryMode.BOUND_REPLY_TARGET, boundReplyTarget)); + assertNotNull(runId); + assertTrue(waitUntil(() -> adapter.messages.contains("自动化结果"), 5000)); + assertEquals("group-1", adapter.outbounds.get(0).getReplyTarget().getConversationId()); + } finally { + scheduler.shutdown(); + } + } + + @Test + void lastRouteDeliveryUsesLatestExternalRoute() throws Exception { + RuntimeStoreService store = new RuntimeStoreService(tempDir.toFile()); + ReplyTarget latestReplyTarget = new ReplyTarget(ChannelType.DINGTALK, ConversationType.GROUP, "group-last", "user-last"); + store.rememberReplyTarget("dingtalk:group:group-last", latestReplyTarget); + + ConversationScheduler scheduler = new ConversationScheduler(1); + ChannelRegistry registry = new ChannelRegistry(); + RecordingChannelAdapter adapter = new RecordingChannelAdapter(); + registry.register(adapter); + SolonClawProperties properties = new SolonClawProperties(); + ConversationAgent conversationAgent = (request, progressConsumer) -> "自动化结果"; + IsolatedAgentRunService service = new IsolatedAgentRunService(conversationAgent, store, scheduler, registry, properties); + + try { + String runId = service.submit(request(JobDeliveryMode.LAST_ROUTE, null)); + assertNotNull(runId); + assertTrue(waitUntil(() -> adapter.messages.contains("自动化结果"), 5000)); + assertEquals("group-last", adapter.outbounds.get(0).getReplyTarget().getConversationId()); + } finally { + scheduler.shutdown(); + } + } + + private AgentTurnRequest request(JobDeliveryMode deliveryMode, ReplyTarget boundReplyTarget) { + AgentTurnRequest request = new AgentTurnRequest(); + request.setJobName("agent-job"); + request.setBoundSessionKey("dingtalk:group:group-1"); + request.setBoundReplyTarget(boundReplyTarget); + request.setDeliveryMode(deliveryMode); + AgentTurnSpec spec = new AgentTurnSpec(); + spec.setMessage("执行自动化任务"); + request.setAgentTurn(spec); + return request; + } + + private boolean waitUntil(BooleanSupplier condition, long timeoutMs) throws InterruptedException { + long deadline = System.currentTimeMillis() + timeoutMs; + while (System.currentTimeMillis() < deadline) { + if (condition.getAsBoolean()) { + return true; + } + Thread.sleep(50); + } + return condition.getAsBoolean(); + } + + private static class RecordingChannelAdapter implements ChannelAdapter { + private final List messages = new CopyOnWriteArrayList(); + private final List outbounds = new CopyOnWriteArrayList(); + + @Override + public ChannelType channelType() { + return ChannelType.DINGTALK; + } + + @Override + public void send(OutboundEnvelope outboundEnvelope) { + outbounds.add(outboundEnvelope); + messages.add(outboundEnvelope.getContent()); + } + } +} diff --git a/src/test/java/com/jimuqu/claw/agent/runtime/SystemEventRunnerTest.java b/src/test/java/com/jimuqu/claw/agent/runtime/SystemEventRunnerTest.java new file mode 100644 index 0000000..77860c4 --- /dev/null +++ b/src/test/java/com/jimuqu/claw/agent/runtime/SystemEventRunnerTest.java @@ -0,0 +1,210 @@ +package com.jimuqu.claw.agent.runtime; + +import com.jimuqu.claw.agent.channel.ChannelAdapter; +import com.jimuqu.claw.agent.channel.ChannelRegistry; +import com.jimuqu.claw.agent.model.envelope.OutboundEnvelope; +import com.jimuqu.claw.agent.model.enums.ChannelType; +import com.jimuqu.claw.agent.model.enums.ConversationType; +import com.jimuqu.claw.agent.model.enums.RuntimeSourceKind; +import com.jimuqu.claw.agent.model.enums.RunStatus; +import com.jimuqu.claw.agent.model.enums.SystemEventPolicy; +import com.jimuqu.claw.agent.model.route.ReplyTarget; +import com.jimuqu.claw.agent.model.run.AgentRun; +import com.jimuqu.claw.agent.runtime.api.ConversationAgent; +import com.jimuqu.claw.agent.runtime.impl.ConversationScheduler; +import com.jimuqu.claw.agent.runtime.impl.SystemEventRunner; +import com.jimuqu.claw.agent.runtime.support.NotificationResult; +import com.jimuqu.claw.agent.runtime.support.SystemEventRequest; +import com.jimuqu.claw.agent.store.RuntimeStoreService; +import com.jimuqu.claw.config.SolonClawProperties; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Path; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.BooleanSupplier; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SystemEventRunnerTest { + @TempDir + Path tempDir; + + @Test + void jobSystemEventCanFallbackDeliverReminderText() throws Exception { + RuntimeStoreService store = new RuntimeStoreService(tempDir.toFile()); + ConversationScheduler scheduler = new ConversationScheduler(1); + ChannelRegistry registry = new ChannelRegistry(); + RecordingChannelAdapter adapter = new RecordingChannelAdapter(); + registry.register(adapter); + SolonClawProperties properties = new SolonClawProperties(); + ConversationAgent conversationAgent = (request, progressConsumer) -> "记得喝水。"; + SystemEventRunner runner = new SystemEventRunner(conversationAgent, store, scheduler, registry, properties); + + try { + SystemEventRequest request = new SystemEventRequest(); + request.setSourceKind(RuntimeSourceKind.JOB_SYSTEM_EVENT); + request.setPolicy(SystemEventPolicy.USER_VISIBLE_OPTIONAL); + request.setSessionKey("dingtalk:group:group-1"); + request.setReplyTarget(new ReplyTarget(ChannelType.DINGTALK, ConversationType.GROUP, "group-1", "user-1")); + request.setContent("提醒我喝水"); + request.setAllowNotifyUser(true); + + String runId = runner.submit(request); + assertNotNull(runId); + assertTrue(waitUntil(() -> adapter.messages.contains("记得喝水。"), 5000)); + assertTrue(store.hasRunEventType(runId, "delivery_fallback_sent")); + } finally { + scheduler.shutdown(); + } + } + + @Test + void heartbeatEventSuppressesPlainUserVisibleReplyByDefault() throws Exception { + RuntimeStoreService store = new RuntimeStoreService(tempDir.toFile()); + ConversationScheduler scheduler = new ConversationScheduler(1); + ChannelRegistry registry = new ChannelRegistry(); + RecordingChannelAdapter adapter = new RecordingChannelAdapter(); + registry.register(adapter); + SolonClawProperties properties = new SolonClawProperties(); + ConversationAgent conversationAgent = (request, progressConsumer) -> "系统状态正常"; + SystemEventRunner runner = new SystemEventRunner(conversationAgent, store, scheduler, registry, properties); + + try { + SystemEventRequest request = new SystemEventRequest(); + request.setSourceKind(RuntimeSourceKind.HEARTBEAT_EVENT); + request.setPolicy(SystemEventPolicy.INTERNAL_ONLY); + request.setSessionKey("dingtalk:group:group-1"); + request.setReplyTarget(new ReplyTarget(ChannelType.DINGTALK, ConversationType.GROUP, "group-1", "user-1")); + request.setContent("heartbeat"); + request.setAllowNotifyUser(true); + + String runId = runner.submit(request); + assertNotNull(runId); + assertTrue(waitUntil(() -> { + AgentRun run = store.getRun(runId); + return run != null && run.getStatus() == RunStatus.SUCCEEDED; + }, 5000)); + assertTrue(adapter.outbounds.isEmpty()); + assertTrue(store.hasRunEventType(runId, "delivery_suppressed")); + } finally { + scheduler.shutdown(); + } + } + + @Test + void heartbeatEventCanNotifyUserExplicitly() throws Exception { + RuntimeStoreService store = new RuntimeStoreService(tempDir.toFile()); + ConversationScheduler scheduler = new ConversationScheduler(1); + ChannelRegistry registry = new ChannelRegistry(); + RecordingChannelAdapter adapter = new RecordingChannelAdapter(); + registry.register(adapter); + SolonClawProperties properties = new SolonClawProperties(); + ConversationAgent conversationAgent = (request, progressConsumer) -> { + NotificationResult result = request.getNotificationSupport().notifyUser("需要人工关注", false); + assertTrue(result.isDelivered()); + return "NO_REPLY"; + }; + SystemEventRunner runner = new SystemEventRunner(conversationAgent, store, scheduler, registry, properties); + + try { + SystemEventRequest request = new SystemEventRequest(); + request.setSourceKind(RuntimeSourceKind.HEARTBEAT_EVENT); + request.setPolicy(SystemEventPolicy.INTERNAL_ONLY); + request.setSessionKey("dingtalk:group:group-1"); + request.setReplyTarget(new ReplyTarget(ChannelType.DINGTALK, ConversationType.GROUP, "group-1", "user-1")); + request.setContent("heartbeat"); + request.setAllowNotifyUser(true); + + String runId = runner.submit(request); + assertNotNull(runId); + assertTrue(waitUntil(() -> adapter.messages.contains("需要人工关注"), 5000)); + assertEquals(1, adapter.outbounds.size()); + assertTrue(store.hasRunEventType(runId, "notify")); + } finally { + scheduler.shutdown(); + } + } + + @Test + void childContinuationDeliversOnlyFinalReplyOnce() throws Exception { + RuntimeStoreService store = new RuntimeStoreService(tempDir.toFile()); + ConversationScheduler scheduler = new ConversationScheduler(1); + ChannelRegistry registry = new ChannelRegistry(); + RecordingChannelAdapter adapter = new RecordingChannelAdapter(); + registry.register(adapter); + SolonClawProperties properties = new SolonClawProperties(); + ConversationAgent conversationAgent = (request, progressConsumer) -> + "FINAL_REPLY_ONCE:聚合完成"; + SystemEventRunner runner = new SystemEventRunner(conversationAgent, store, scheduler, registry, properties); + + try { + AgentRun parentRun = new AgentRun(); + parentRun.setRunId("parent-run"); + parentRun.setSessionKey("dingtalk:group:group-1"); + parentRun.setSourceKind(RuntimeSourceKind.USER_MESSAGE); + parentRun.setSourceUserVersion(1L); + store.saveRun(parentRun); + + AgentRun child = new AgentRun(); + child.setRunId("child-1"); + child.setParentRunId("parent-run"); + child.setParentSessionKey("dingtalk:group:group-1"); + child.setStatus(RunStatus.SUCCEEDED); + store.saveRun(child); + + SystemEventRequest request = new SystemEventRequest(); + request.setSourceKind(RuntimeSourceKind.CHILD_CONTINUATION); + request.setPolicy(SystemEventPolicy.AGGREGATE_ONLY); + request.setSessionKey("dingtalk:group:group-1"); + request.setReplyTarget(new ReplyTarget(ChannelType.DINGTALK, ConversationType.GROUP, "group-1", "user-1")); + request.setContent("child completed"); + request.setRelatedRunId("parent-run"); + + String firstRunId = runner.submit(request); + assertNotNull(firstRunId); + assertTrue(waitUntil(() -> adapter.messages.contains("聚合完成"), 5000)); + assertTrue(store.hasRunEventType("parent-run", "children_aggregated")); + + String secondRunId = runner.submit(request); + assertNotNull(secondRunId); + assertTrue(waitUntil(() -> { + AgentRun run = store.getRun(secondRunId); + return run != null && run.getStatus() == RunStatus.SUCCEEDED; + }, 5000)); + assertEquals(1, adapter.outbounds.stream().filter(outbound -> "聚合完成".equals(outbound.getContent())).count()); + } finally { + scheduler.shutdown(); + } + } + + private boolean waitUntil(BooleanSupplier condition, long timeoutMs) throws InterruptedException { + long deadline = System.currentTimeMillis() + timeoutMs; + while (System.currentTimeMillis() < deadline) { + if (condition.getAsBoolean()) { + return true; + } + Thread.sleep(50); + } + return condition.getAsBoolean(); + } + + private static class RecordingChannelAdapter implements ChannelAdapter { + private final List messages = new CopyOnWriteArrayList(); + private final List outbounds = new CopyOnWriteArrayList(); + + @Override + public ChannelType channelType() { + return ChannelType.DINGTALK; + } + + @Override + public void send(OutboundEnvelope outboundEnvelope) { + outbounds.add(outboundEnvelope); + messages.add(outboundEnvelope.getContent()); + } + } +} 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 b5d7e7c..24e25dd 100644 --- a/src/test/java/com/jimuqu/claw/agent/store/RuntimeStoreServiceTest.java +++ b/src/test/java/com/jimuqu/claw/agent/store/RuntimeStoreServiceTest.java @@ -1,14 +1,14 @@ package com.jimuqu.claw.agent.store; -import com.jimuqu.claw.agent.model.run.AgentRun; +import com.jimuqu.claw.agent.model.envelope.InboundEnvelope; import com.jimuqu.claw.agent.model.enums.ChannelType; -import com.jimuqu.claw.agent.model.event.ConversationEvent; import com.jimuqu.claw.agent.model.enums.ConversationType; -import com.jimuqu.claw.agent.model.envelope.InboundEnvelope; -import com.jimuqu.claw.agent.model.enums.InboundTriggerType; -import com.jimuqu.claw.agent.model.route.ReplyTarget; -import com.jimuqu.claw.agent.model.event.RunEvent; +import com.jimuqu.claw.agent.model.enums.RuntimeSourceKind; import com.jimuqu.claw.agent.model.enums.RunStatus; +import com.jimuqu.claw.agent.model.event.ConversationEvent; +import com.jimuqu.claw.agent.model.event.RunEvent; +import com.jimuqu.claw.agent.model.route.ReplyTarget; +import com.jimuqu.claw.agent.model.run.AgentRun; import com.jimuqu.claw.agent.runtime.support.ParentRunChildrenSummary; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -21,28 +21,21 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; -/** - * 验证运行时存储服务的持久化和恢复行为。 - */ class RuntimeStoreServiceTest { - /** 临时测试目录。 */ @TempDir Path tempDir; - /** - * 验证历史消息会按入站顺序重建。 - */ @Test void loadConversationHistoryBeforeKeepsInboundOrder() { RuntimeStoreService store = new RuntimeStoreService(tempDir.toFile()); - InboundEnvelope first = inbound("session-a", "msg-1", "first"); - InboundEnvelope second = inbound("session-a", "msg-2", "second"); + InboundEnvelope first = inbound("session-a", "msg-1", "first", RuntimeSourceKind.USER_MESSAGE); + InboundEnvelope second = inbound("session-a", "msg-2", "second", RuntimeSourceKind.USER_MESSAGE); long firstVersion = store.appendInboundConversationEvent(first); long secondVersion = store.appendInboundConversationEvent(second); - store.appendAssistantConversationEvent("session-a", "run-1", "msg-1", firstVersion, "reply-first"); - store.appendAssistantConversationEvent("session-a", "run-2", "msg-2", secondVersion, "reply-second"); + store.appendAssistantConversationEvent("session-a", "run-1", "msg-1", firstVersion, RuntimeSourceKind.USER_MESSAGE, "reply-first"); + store.appendAssistantConversationEvent("session-a", "run-2", "msg-2", secondVersion, RuntimeSourceKind.USER_MESSAGE, "reply-second"); List history = store.loadConversationHistoryBefore("session-a", 5L); @@ -53,15 +46,13 @@ class RuntimeStoreServiceTest { assertEquals("reply-second", history.get(3).getContent()); } - /** - * 验证重启后未完成任务会被标记为中止。 - */ @Test void marksIncompleteRunsAbortedOnStartup() { RuntimeStoreService store = new RuntimeStoreService(tempDir.toFile()); AgentRun run = new AgentRun(); run.setRunId("run-abort"); run.setSessionKey("debug-web:test"); + run.setSourceKind(RuntimeSourceKind.USER_MESSAGE); run.setStatus(RunStatus.RUNNING); run.setCreatedAt(System.currentTimeMillis()); store.saveRun(run); @@ -74,11 +65,9 @@ class RuntimeStoreServiceTest { List events = restarted.getRunEvents("run-abort", 0); assertEquals("aborted", events.get(events.size() - 1).getMessage()); + assertEquals(RuntimeSourceKind.USER_MESSAGE, events.get(0).getSourceKind()); } - /** - * 验证最近外部路由会带上会话键一起保存。 - */ @Test void remembersLatestExternalRouteWithSessionKey() { RuntimeStoreService store = new RuntimeStoreService(tempDir.toFile()); @@ -91,14 +80,11 @@ class RuntimeStoreServiceTest { assertEquals("cid", store.getReplyTarget("dingtalk:group:cid").getConversationId()); } - /** - * 验证结构化子任务事件会以系统消息形式进入会话历史。 - */ @Test void loadConversationHistoryBeforeIncludesStructuredChildEvents() { RuntimeStoreService store = new RuntimeStoreService(tempDir.toFile()); - InboundEnvelope parent = inbound("session-a", "msg-1", "parent-question"); + InboundEnvelope parent = inbound("session-a", "msg-1", "parent-question", RuntimeSourceKind.USER_MESSAGE); long parentVersion = store.appendInboundConversationEvent(parent); AgentRun childRun = new AgentRun(); @@ -121,129 +107,37 @@ class RuntimeStoreServiceTest { assertTrue(history.get(2).getContent().contains("result=")); } - /** - * 验证可见系统消息会以 system 角色写入会话事件。 - */ @Test - void appendInboundConversationEventUsesSystemRoleForVisibleSystemTrigger() { + void appendInboundConversationEventUsesSourceKindForRoleAndEventType() { RuntimeStoreService store = new RuntimeStoreService(tempDir.toFile()); - InboundEnvelope user = inbound("session-a", "msg-1", "question"); + InboundEnvelope user = inbound("session-a", "msg-1", "question", RuntimeSourceKind.USER_MESSAGE); long userVersion = store.appendInboundConversationEvent(user); - InboundEnvelope system = inbound("session-a", "system-1", "scheduled-task"); + InboundEnvelope system = inbound("session-a", "system-1", "heartbeat", RuntimeSourceKind.HEARTBEAT_EVENT); system.setChannelType(ChannelType.SYSTEM); - system.setTriggerType(InboundTriggerType.SYSTEM_VISIBLE); system.setHistoryAnchorVersion(userVersion); - long systemVersion = store.appendInboundConversationEvent(system); - List events = store.readConversationEvents("session-a"); + List events = store.readConversationEvents("session-a"); assertEquals(2L, systemVersion); assertEquals("user_message", events.get(0).getEventType()); + assertEquals("user", events.get(0).getRole()); + assertEquals(RuntimeSourceKind.USER_MESSAGE, events.get(0).getSourceKind()); assertEquals("system_event", events.get(1).getEventType()); assertEquals("system", events.get(1).getRole()); - assertEquals(userVersion, events.get(1).getSourceUserVersion()); + assertEquals(RuntimeSourceKind.HEARTBEAT_EVENT, events.get(1).getSourceKind()); } - /** - * 验证同一锚点下的系统事件与回复会按事件版本顺序重建。 - */ - @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()); - } - - /** - * 验证可按父运行聚合多个子任务状态。 - */ - @Test - void summarizeChildRunsByParentRun() { - RuntimeStoreService store = new RuntimeStoreService(tempDir.toFile()); - - AgentRun child1 = new AgentRun(); - child1.setRunId("child-1"); - child1.setParentRunId("parent-1"); - child1.setParentSessionKey("session-a"); - child1.setTaskDescription("task-1"); - child1.setStatus(RunStatus.SUCCEEDED); - child1.setCreatedAt(1L); - store.saveRun(child1); - - AgentRun child2 = new AgentRun(); - child2.setRunId("child-2"); - child2.setParentRunId("parent-1"); - child2.setParentSessionKey("session-a"); - child2.setTaskDescription("task-2"); - child2.setStatus(RunStatus.RUNNING); - child2.setCreatedAt(2L); - store.saveRun(child2); - - ParentRunChildrenSummary summary = store.summarizeChildRuns("parent-1"); - - assertEquals("parent-1", summary.getParentRunId()); - assertEquals(2, summary.getTotalChildren()); - assertEquals(1, summary.getSucceededChildren()); - assertEquals(0, summary.getFailedChildren()); - assertEquals(1, summary.getPendingChildren()); - assertTrue(!summary.isAllCompleted()); - } - - /** - * 验证可按 batchKey 只聚合同一父运行下的一批子任务。 - */ @Test void summarizeChildRunsByParentRunAndBatchKey() { RuntimeStoreService store = new RuntimeStoreService(tempDir.toFile()); - AgentRun batchA1 = new AgentRun(); - batchA1.setRunId("child-a1"); - batchA1.setParentRunId("parent-1"); - batchA1.setParentSessionKey("session-a"); - batchA1.setBatchKey("plan-A"); - batchA1.setTaskDescription("task-a1"); - batchA1.setStatus(RunStatus.SUCCEEDED); - batchA1.setCreatedAt(1L); + AgentRun batchA1 = child("child-a1", "parent-1", "session-a", "plan-A", RunStatus.SUCCEEDED, 1L); + AgentRun batchA2 = child("child-a2", "parent-1", "session-a", "plan-A", RunStatus.RUNNING, 2L); + AgentRun batchB1 = child("child-b1", "parent-1", "session-a", "plan-B", RunStatus.SUCCEEDED, 3L); store.saveRun(batchA1); - - AgentRun batchA2 = new AgentRun(); - batchA2.setRunId("child-a2"); - batchA2.setParentRunId("parent-1"); - batchA2.setParentSessionKey("session-a"); - batchA2.setBatchKey("plan-A"); - batchA2.setTaskDescription("task-a2"); - batchA2.setStatus(RunStatus.RUNNING); - batchA2.setCreatedAt(2L); store.saveRun(batchA2); - - AgentRun batchB1 = new AgentRun(); - batchB1.setRunId("child-b1"); - batchB1.setParentRunId("parent-1"); - batchB1.setParentSessionKey("session-a"); - batchB1.setBatchKey("plan-B"); - batchB1.setTaskDescription("task-b1"); - batchB1.setStatus(RunStatus.SUCCEEDED); - batchB1.setCreatedAt(3L); store.saveRun(batchB1); ParentRunChildrenSummary summary = store.summarizeChildRuns("parent-1", "plan-A"); @@ -255,15 +149,7 @@ class RuntimeStoreServiceTest { assertTrue(!summary.isAllCompleted()); } - /** - * 构造一条简化版入站消息。 - * - * @param sessionKey 会话键 - * @param messageId 消息标识 - * @param content 文本内容 - * @return 入站消息 - */ - private InboundEnvelope inbound(String sessionKey, String messageId, String content) { + private InboundEnvelope inbound(String sessionKey, String messageId, String content, RuntimeSourceKind sourceKind) { InboundEnvelope envelope = new InboundEnvelope(); envelope.setSessionKey(sessionKey); envelope.setMessageId(messageId); @@ -273,8 +159,19 @@ class RuntimeStoreServiceTest { envelope.setSenderId("user"); envelope.setContent(content); envelope.setReceivedAt(System.currentTimeMillis()); + envelope.setSourceKind(sourceKind); return envelope; } -} - + private AgentRun child(String runId, String parentRunId, String parentSessionKey, String batchKey, RunStatus status, long createdAt) { + AgentRun run = new AgentRun(); + run.setRunId(runId); + run.setParentRunId(parentRunId); + run.setParentSessionKey(parentSessionKey); + run.setBatchKey(batchKey); + run.setStatus(status); + run.setCreatedAt(createdAt); + run.setSourceKind(RuntimeSourceKind.USER_MESSAGE); + return run; + } +} -- Gitee From d18a8e16db846a108d891edc1be050a760a7382b Mon Sep 17 00:00:00 2001 From: chengliang Date: Fri, 20 Mar 2026 10:35:28 +0800 Subject: [PATCH 06/10] =?UTF-8?q?docs(runtime):=20=E6=94=B6=E7=B4=A7?= =?UTF-8?q?=E9=95=BF=E4=BB=BB=E5=8A=A1=E6=8B=86=E5=88=86=E6=8F=90=E7=A4=BA?= =?UTF-8?q?=E8=AF=8D=E4=B8=8E=E5=AD=90=E4=BB=BB=E5=8A=A1=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E6=8F=8F=E8=BF=B0=20(Tighten=20long-task=20splitting=20guidanc?= =?UTF-8?q?e=20and=20tool=20wording)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../claw/agent/tool/ConversationRuntimeTools.java | 12 ++++++------ .../claw/agent/workspace/WorkspacePromptService.java | 10 +++++----- 2 files changed, 11 insertions(+), 11 deletions(-) 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 aaa9e56..b6c658c 100644 --- a/src/main/java/com/jimuqu/claw/agent/tool/ConversationRuntimeTools.java +++ b/src/main/java/com/jimuqu/claw/agent/tool/ConversationRuntimeTools.java @@ -85,10 +85,10 @@ public class ConversationRuntimeTools { : "通知失败: " + result.getMessage(); } - @ToolMapping(name = "spawn_task", description = "派生一个独立子运行处理较大任务;子运行完成后会以内部事件回流父会话") + @ToolMapping(name = "spawn_task", description = "默认用于长任务、复杂任务和可并行任务的首选拆分方式。适合仓库克隆、编译、部署、排查、并行探索等场景;子任务完成后会自动以内部事件回流父会话,便于继续汇总与追踪进度") public String spawnTask( - @Param(description = "子任务描述,建议写成清晰的执行目标") String taskDescription, - @Param(description = "可选的批次键/计划键;同一批任务可复用同一个 batchKey") String batchKey + @Param(description = "子任务描述,建议写成单一、清晰、可执行的目标;不要直接复述整段对话") String taskDescription, + @Param(description = "可选的批次键/计划键;同一批长任务或同一阶段的子任务可复用同一个 batchKey,方便后续统一汇总") String batchKey ) { if (spawnTaskSupport == null) { return "当前运行不支持 spawn_task"; @@ -105,7 +105,7 @@ public class ConversationRuntimeTools { } } - @ToolMapping(name = "list_child_runs", description = "查看当前会话最近的子任务列表,返回 runId、状态、任务描述和结果摘要") + @ToolMapping(name = "list_child_runs", description = "查看当前会话最近的子任务列表。适合在长任务拆分后追踪后台进度,返回 runId、状态、任务描述和结果摘要") public String listChildRuns( @Param(description = "最大返回条数,默认 5") Integer limit, @Param(description = "可选批次键;传入后仅查看该批次的子任务") String batchKey @@ -145,7 +145,7 @@ public class ConversationRuntimeTools { return builder.toString().trim(); } - @ToolMapping(name = "get_run_status", description = "查看指定 runId 的状态;若不传 runId,则默认查看最近一个子任务") + @ToolMapping(name = "get_run_status", description = "查看指定 runId 的状态。适合在长任务执行过程中追踪某个关键子任务;若不传 runId,则默认查看最近一个子任务") public String getRunStatus( @Param(description = "运行任务标识,可为空;为空时默认查看最近一个子任务") String runId ) { @@ -177,7 +177,7 @@ public class ConversationRuntimeTools { return builder.toString().trim(); } - @ToolMapping(name = "get_child_summary", description = "聚合查看某个父运行下的全部子任务状态;不传 parentRunId 时默认查看最近一个有子任务的父运行") + @ToolMapping(name = "get_child_summary", description = "聚合查看某个父运行下的全部子任务状态。适合在父任务需要统一汇总前,快速判断整批长任务是否都已完成;不传 parentRunId 时默认查看最近一个有子任务的父运行") public String getChildSummary( @Param(description = "父运行标识,可为空;为空时默认查看最近一个有子任务的父运行") String parentRunId, @Param(description = "可选批次键;传入后仅聚合该批次的子任务") String batchKey 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 b9dbf3a..afa5269 100644 --- a/src/main/java/com/jimuqu/claw/agent/workspace/WorkspacePromptService.java +++ b/src/main/java/com/jimuqu/claw/agent/workspace/WorkspacePromptService.java @@ -258,12 +258,12 @@ public class WorkspacePromptService { } lines.add("## 长任务与子任务"); - lines.add("- 当任务涉及多个相对独立的步骤、多个目录或服务、长时间命令、并行探索多个方向,或用户会受益于后台持续推进时,优先考虑用 `spawn_task` 拆分,而不是在一个 run 里硬做到底。"); - lines.add("- 典型适合拆分的场景:仓库部署与启动、编译/测试/回归、并行查文档或代码、分模块排查日志或配置、批量检查多个候选方案。"); - lines.add("- 子任务描述应写成单一、清晰、可执行的目标,避免把整段原始对话原样塞给子任务。"); - lines.add("- 派生后,父任务可先继续规划、记录进度或返回 `NO_REPLY`;如果要等全部子任务完成后统一回复,可使用 `FINAL_REPLY_ONCE:` 只发送一次最终聚合结果。"); + lines.add("- 默认把较大任务拆成子任务处理,而不是在一个 run 里硬做到底。"); + lines.add("- 只有在任务只是纯文字回答,不需要查资料、不需要读文件、不需要调用工具、也不需要较长时间执行时,才不要拆分。"); + lines.add("- 父任务负责规划、跟踪和汇总;子任务负责完成单一、清晰、可执行的目标。"); + lines.add("- 派生后,父任务可先记录进度或返回 `NO_REPLY`;如果要等全部子任务完成后统一回复,可使用 `FINAL_REPLY_ONCE:` 只发送一次最终聚合结果。"); lines.add("- 使用 `list_child_runs`、`get_run_status`、`get_child_summary` 跟踪子任务进度和批次聚合结果。"); - lines.add("- 对特别小、特别快、明显串行的动作不要滥用子任务;拆分是为了提高清晰度、并行度和长任务稳定性。"); + lines.add("- 如果一个动作明显很小、很快、一步可完成,也可以直接做,不必强行拆分。"); } /** -- Gitee From b7a612fb793c4fa865019da94b7070ab2eb27f76 Mon Sep 17 00:00:00 2001 From: chengliang Date: Fri, 20 Mar 2026 15:36:26 +0800 Subject: [PATCH 07/10] =?UTF-8?q?refactor(runtime):=20=E6=94=B6=E6=95=9B?= =?UTF-8?q?=E4=B8=BA=E7=BB=93=E6=9E=84=E5=8C=96=E5=8F=91=E9=80=81=E7=BB=93?= =?UTF-8?q?=E6=9E=9C=E5=B9=B6=E7=A7=BB=E9=99=A4=20debug-web=20=E7=89=B9?= =?UTF-8?q?=E6=AE=8A=E8=AF=AD=E4=B9=89=20(Keep=20structured=20delivery=20r?= =?UTF-8?q?esults=20and=20remove=20debug-web=20special=20semantics)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 +- .../claw/agent/channel/ChannelAdapter.java | 3 +- .../claw/agent/channel/ChannelRegistry.java | 14 ++- .../claw/agent/model/route/ReplyTarget.java | 9 -- .../runtime/impl/AgentRuntimeService.java | 97 ++++++++++++------- .../runtime/impl/IsolatedAgentRunService.java | 63 ++++++++++-- .../agent/runtime/impl/SystemEventRunner.java | 65 +++++++++++-- .../agent/runtime/support/DeliveryResult.java | 33 +++++++ .../runtime/support/NotificationResult.java | 12 +++ .../claw/agent/store/RuntimeStoreService.java | 4 - .../adapter/DingTalkChannelAdapter.java | 11 ++- .../dingtalk/sender/DingTalkRobotSender.java | 31 +++++- .../feishu/adapter/FeishuChannelAdapter.java | 5 +- .../feishu/sender/FeishuBotSender.java | 34 ++++++- .../web/controller/DebugChatController.java | 19 +++- src/main/resources/static/index.html | 2 +- .../agent/job/WorkspaceJobServiceTest.java | 32 +++++- .../runtime/AgentRuntimeServiceTest.java | 61 ++++++++++-- .../runtime/IsolatedAgentRunServiceTest.java | 11 ++- .../agent/runtime/SystemEventRunnerTest.java | 10 +- .../channel/feishu/FeishuBotSenderTest.java | 1 - 21 files changed, 417 insertions(+), 102 deletions(-) create mode 100644 src/main/java/com/jimuqu/claw/agent/runtime/support/DeliveryResult.java diff --git a/.gitignore b/.gitignore index 3b758ce..158b0f3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ target/ !.mvn/wrapper/maven-wrapper.jar logs/ -workspace/ +/workspace/ src/main/resources/app-dev.yml ### STS ### 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 17ec490..0360ee2 100644 --- a/src/main/java/com/jimuqu/claw/agent/channel/ChannelAdapter.java +++ b/src/main/java/com/jimuqu/claw/agent/channel/ChannelAdapter.java @@ -2,6 +2,7 @@ package com.jimuqu.claw.agent.channel; import com.jimuqu.claw.agent.model.enums.ChannelType; import com.jimuqu.claw.agent.model.envelope.OutboundEnvelope; +import com.jimuqu.claw.agent.runtime.support.DeliveryResult; /** * 抽象统一的消息渠道适配器接口。 @@ -28,6 +29,6 @@ public interface ChannelAdapter { * * @param outboundEnvelope 出站消息 */ - void send(OutboundEnvelope outboundEnvelope); + DeliveryResult send(OutboundEnvelope outboundEnvelope); } diff --git a/src/main/java/com/jimuqu/claw/agent/channel/ChannelRegistry.java b/src/main/java/com/jimuqu/claw/agent/channel/ChannelRegistry.java index 343c9a9..d0a039a 100644 --- a/src/main/java/com/jimuqu/claw/agent/channel/ChannelRegistry.java +++ b/src/main/java/com/jimuqu/claw/agent/channel/ChannelRegistry.java @@ -2,6 +2,7 @@ package com.jimuqu.claw.agent.channel; import com.jimuqu.claw.agent.model.enums.ChannelType; import com.jimuqu.claw.agent.model.envelope.OutboundEnvelope; +import com.jimuqu.claw.agent.runtime.support.DeliveryResult; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @@ -37,15 +38,22 @@ public class ChannelRegistry { * * @param outboundEnvelope 出站消息 */ - public void send(OutboundEnvelope outboundEnvelope) { + public DeliveryResult send(OutboundEnvelope outboundEnvelope) { + DeliveryResult result = new DeliveryResult(); if (outboundEnvelope == null || outboundEnvelope.getReplyTarget() == null) { - return; + result.setDelivered(false); + result.setMessage("missing reply target"); + return result; } ChannelAdapter adapter = adapters.get(outboundEnvelope.getReplyTarget().getChannelType()); if (adapter != null) { - adapter.send(outboundEnvelope); + return adapter.send(outboundEnvelope); } + result.setDelivered(false); + result.setChannelType(outboundEnvelope.getReplyTarget().getChannelType()); + result.setMessage("channel adapter not found"); + return result; } } diff --git a/src/main/java/com/jimuqu/claw/agent/model/route/ReplyTarget.java b/src/main/java/com/jimuqu/claw/agent/model/route/ReplyTarget.java index ecbba41..9b7613b 100644 --- a/src/main/java/com/jimuqu/claw/agent/model/route/ReplyTarget.java +++ b/src/main/java/com/jimuqu/claw/agent/model/route/ReplyTarget.java @@ -42,13 +42,4 @@ public class ReplyTarget implements Serializable { this.conversationId = conversationId; this.userId = userId; } - - /** - * 判断当前目标是否属于调试页渠道。 - * - * @return 若为调试页则返回 true - */ - public boolean isDebugWeb() { - return channelType == ChannelType.DEBUG_WEB; - } } diff --git a/src/main/java/com/jimuqu/claw/agent/runtime/impl/AgentRuntimeService.java b/src/main/java/com/jimuqu/claw/agent/runtime/impl/AgentRuntimeService.java index cf5ae66..6202fb5 100644 --- a/src/main/java/com/jimuqu/claw/agent/runtime/impl/AgentRuntimeService.java +++ b/src/main/java/com/jimuqu/claw/agent/runtime/impl/AgentRuntimeService.java @@ -18,6 +18,7 @@ import com.jimuqu.claw.agent.runtime.api.NotificationSupport; import com.jimuqu.claw.agent.runtime.api.RunQuerySupport; import com.jimuqu.claw.agent.runtime.api.SpawnTaskSupport; import com.jimuqu.claw.agent.runtime.support.ConversationExecutionRequest; +import com.jimuqu.claw.agent.runtime.support.DeliveryResult; import com.jimuqu.claw.agent.runtime.support.NotificationResult; import com.jimuqu.claw.agent.runtime.support.ParentRunChildrenSummary; import com.jimuqu.claw.agent.runtime.support.SpawnTaskResult; @@ -61,22 +62,6 @@ public class AgentRuntimeService { this.properties = properties; } - public String submitDebugMessage(String sessionId, String message) { - InboundEnvelope inboundEnvelope = new InboundEnvelope(); - inboundEnvelope.setMessageId("debug-" + IdUtil.fastSimpleUUID()); - inboundEnvelope.setChannelType(ChannelType.DEBUG_WEB); - inboundEnvelope.setChannelInstanceId("debug-web"); - inboundEnvelope.setSenderId("debug-user"); - inboundEnvelope.setConversationId(sessionId); - inboundEnvelope.setConversationType(ConversationType.PRIVATE); - inboundEnvelope.setContent(message); - inboundEnvelope.setReceivedAt(System.currentTimeMillis()); - inboundEnvelope.setSessionKey("debug-web:" + sessionId); - inboundEnvelope.setReplyTarget(new ReplyTarget(ChannelType.DEBUG_WEB, ConversationType.PRIVATE, sessionId, "debug-user")); - inboundEnvelope.setSourceKind(RuntimeSourceKind.USER_MESSAGE); - return submitInbound(inboundEnvelope); - } - public String submitInbound(InboundEnvelope inboundEnvelope) { if (inboundEnvelope == null) { return null; @@ -104,7 +89,7 @@ public class AgentRuntimeService { inboundEnvelope.setHistoryAnchorVersion(nextConversationVersion); long version = runtimeStoreService.appendInboundConversationEvent(inboundEnvelope); inboundEnvelope.setSessionVersion(version); - if (inboundEnvelope.getReplyTarget() != null && !inboundEnvelope.getReplyTarget().isDebugWeb()) { + if (inboundEnvelope.getReplyTarget() != null) { runtimeStoreService.rememberReplyTarget(inboundEnvelope.getSessionKey(), inboundEnvelope.getReplyTarget()); } log.info( @@ -129,13 +114,13 @@ public class AgentRuntimeService { if (properties.getAgent().getScheduler().isAckWhenBusy() && state.activeCount() > 0 - && inboundEnvelope.getReplyTarget() != null - && !inboundEnvelope.getReplyTarget().isDebugWeb()) { + && inboundEnvelope.getReplyTarget() != null) { OutboundEnvelope ack = new OutboundEnvelope(); ack.setRunId(run.getRunId()); ack.setReplyTarget(inboundEnvelope.getReplyTarget()); ack.setContent(state.queuedCount() > 0 ? "已收到,排队处理中。" : "已收到,正在并行处理中。"); - channelRegistry.send(ack); + DeliveryResult deliveryResult = channelRegistry.send(ack); + recordDeliveryResult(run.getRunId(), deliveryResult); } conversationScheduler.submit(inboundEnvelope.getSessionKey(), () -> processRun(inboundEnvelope, run.getRunId())); @@ -245,13 +230,13 @@ public class AgentRuntimeService { handleChildRunCompletion(run); if (!suppressReply - && inboundEnvelope.getReplyTarget() != null - && !inboundEnvelope.getReplyTarget().isDebugWeb()) { + && inboundEnvelope.getReplyTarget() != null) { OutboundEnvelope outboundEnvelope = new OutboundEnvelope(); outboundEnvelope.setRunId(runId); outboundEnvelope.setReplyTarget(inboundEnvelope.getReplyTarget()); outboundEnvelope.setContent(visibleResponse); - channelRegistry.send(outboundEnvelope); + DeliveryResult deliveryResult = channelRegistry.send(outboundEnvelope); + recordDeliveryResult(runId, deliveryResult); log.info( "Run {} reply dispatched. channelType={}, conversationType={}, conversationId={}", runId, @@ -270,13 +255,13 @@ public class AgentRuntimeService { log.warn("Run {} failed for session {}: {}", runId, inboundEnvelope.getSessionKey(), throwable.getMessage(), throwable); handleChildRunCompletion(run); - if (inboundEnvelope.getReplyTarget() != null - && !inboundEnvelope.getReplyTarget().isDebugWeb()) { + if (inboundEnvelope.getReplyTarget() != null) { OutboundEnvelope outboundEnvelope = new OutboundEnvelope(); outboundEnvelope.setRunId(runId); outboundEnvelope.setReplyTarget(inboundEnvelope.getReplyTarget()); outboundEnvelope.setContent("抱歉,这次处理失败了:" + throwable.getMessage()); - channelRegistry.send(outboundEnvelope); + DeliveryResult deliveryResult = channelRegistry.send(outboundEnvelope); + recordDeliveryResult(runId, deliveryResult); } } } @@ -557,11 +542,15 @@ public class AgentRuntimeService { outboundEnvelope.setReplyTarget(replyTarget); outboundEnvelope.setContent(message); outboundEnvelope.setProgress(progress); - channelRegistry.send(outboundEnvelope); + DeliveryResult deliveryResult = channelRegistry.send(outboundEnvelope); + recordDeliveryResult(runId, deliveryResult); + applyDeliveryResult(result, deliveryResult); runtimeStoreService.appendRunEvent(runId, progress ? "notify_progress" : "notify", message); - result.setDelivered(true); - result.setMessage("sent to " + replyTarget.getChannelType() + ":" + replyTarget.getConversationId()); + result.setDelivered(deliveryResult.isDelivered()); + if (StrUtil.isBlank(result.getMessage())) { + result.setMessage("sent to " + replyTarget.getChannelType() + ":" + replyTarget.getConversationId()); + } return result; }; } @@ -569,8 +558,7 @@ public class AgentRuntimeService { private void dispatchProgressOutbound(String runId, InboundEnvelope inboundEnvelope, String progress) { if (StrUtil.isBlank(progress) || inboundEnvelope == null - || inboundEnvelope.getReplyTarget() == null - || inboundEnvelope.getReplyTarget().isDebugWeb()) { + || inboundEnvelope.getReplyTarget() == null) { return; } @@ -584,7 +572,8 @@ public class AgentRuntimeService { outboundEnvelope.setReplyTarget(inboundEnvelope.getReplyTarget()); outboundEnvelope.setContent(progress); outboundEnvelope.setProgress(true); - channelRegistry.send(outboundEnvelope); + DeliveryResult deliveryResult = channelRegistry.send(outboundEnvelope); + recordDeliveryResult(runId, deliveryResult); } private boolean isNoReply(String response) { @@ -619,12 +608,13 @@ public class AgentRuntimeService { fallback ); - if (inboundEnvelope.getReplyTarget() != null && !inboundEnvelope.getReplyTarget().isDebugWeb()) { + if (inboundEnvelope.getReplyTarget() != null) { OutboundEnvelope outboundEnvelope = new OutboundEnvelope(); outboundEnvelope.setRunId(runId); outboundEnvelope.setReplyTarget(inboundEnvelope.getReplyTarget()); outboundEnvelope.setContent(fallback); - channelRegistry.send(outboundEnvelope); + DeliveryResult deliveryResult = channelRegistry.send(outboundEnvelope); + recordDeliveryResult(runId, deliveryResult); log.info( "Run {} empty response fallback dispatched. channelType={}, conversationType={}, conversationId={}", runId, @@ -634,4 +624,43 @@ public class AgentRuntimeService { ); } } + + private void applyDeliveryResult(NotificationResult result, DeliveryResult deliveryResult) { + if (result == null || deliveryResult == null) { + return; + } + result.setTruncated(deliveryResult.isTruncated()); + result.setSegmented(deliveryResult.isSegmented()); + result.setSegmentCount(deliveryResult.getSegmentCount()); + result.setOriginalLength(deliveryResult.getOriginalLength()); + result.setFinalLength(deliveryResult.getFinalLength()); + result.setChannelType(deliveryResult.getChannelType() == null ? null : deliveryResult.getChannelType().name()); + result.setMessage(deliveryResult.getMessage()); + } + + private void recordDeliveryResult(String runId, DeliveryResult deliveryResult) { + if (deliveryResult == null) { + return; + } + StringBuilder message = new StringBuilder(); + message.append("channel=").append(deliveryResult.getChannelType()) + .append(", segmentCount=").append(deliveryResult.getSegmentCount()) + .append(", originalLength=").append(deliveryResult.getOriginalLength()) + .append(", finalLength=").append(deliveryResult.getFinalLength()); + if (StrUtil.isNotBlank(deliveryResult.getMessage())) { + message.append(", detail=").append(deliveryResult.getMessage()); + } + + if (!deliveryResult.isDelivered()) { + runtimeStoreService.appendRunEvent(runId, "delivery_failed", message.toString()); + return; + } + runtimeStoreService.appendRunEvent(runId, "delivery_sent", message.toString()); + if (deliveryResult.isSegmented()) { + runtimeStoreService.appendRunEvent(runId, "delivery_segmented", message.toString()); + } + if (deliveryResult.isTruncated()) { + runtimeStoreService.appendRunEvent(runId, "delivery_truncated", message.toString()); + } + } } diff --git a/src/main/java/com/jimuqu/claw/agent/runtime/impl/IsolatedAgentRunService.java b/src/main/java/com/jimuqu/claw/agent/runtime/impl/IsolatedAgentRunService.java index 10246f1..d86a8b4 100644 --- a/src/main/java/com/jimuqu/claw/agent/runtime/impl/IsolatedAgentRunService.java +++ b/src/main/java/com/jimuqu/claw/agent/runtime/impl/IsolatedAgentRunService.java @@ -14,6 +14,7 @@ import com.jimuqu.claw.agent.runtime.api.ConversationAgent; import com.jimuqu.claw.agent.runtime.api.NotificationSupport; import com.jimuqu.claw.agent.runtime.support.AgentTurnRequest; import com.jimuqu.claw.agent.runtime.support.ConversationExecutionRequest; +import com.jimuqu.claw.agent.runtime.support.DeliveryResult; import com.jimuqu.claw.agent.runtime.support.NotificationResult; import com.jimuqu.claw.agent.store.RuntimeStoreService; import com.jimuqu.claw.config.SolonClawProperties; @@ -139,7 +140,7 @@ public class IsolatedAgentRunService { } ReplyTarget replyTarget = resolveReplyTarget(request.getDeliveryMode(), request.getBoundReplyTarget()); - if (replyTarget == null || replyTarget.isDebugWeb()) { + if (replyTarget == null) { runtimeStoreService.appendRunEvent(runId, "delivery_suppressed", visibleResponse); return false; } @@ -148,9 +149,10 @@ public class IsolatedAgentRunService { outboundEnvelope.setRunId(runId); outboundEnvelope.setReplyTarget(replyTarget); outboundEnvelope.setContent(visibleResponse); - channelRegistry.send(outboundEnvelope); + DeliveryResult deliveryResult = channelRegistry.send(outboundEnvelope); + recordDeliveryResult(runId, deliveryResult); runtimeStoreService.appendRunEvent(runId, "delivery_fallback_sent", visibleResponse); - return true; + return deliveryResult.isDelivered(); } private NotificationSupport buildNotificationSupport(AgentTurnRequest request, String runId) { @@ -169,7 +171,7 @@ public class IsolatedAgentRunService { } ReplyTarget replyTarget = resolveReplyTarget(request.getDeliveryMode(), request.getBoundReplyTarget()); - if (replyTarget == null || replyTarget.isDebugWeb()) { + if (replyTarget == null) { result.setDelivered(false); result.setMessage("当前 agentTurn 没有可用的 ReplyTarget"); return result; @@ -180,11 +182,15 @@ public class IsolatedAgentRunService { outboundEnvelope.setReplyTarget(replyTarget); outboundEnvelope.setContent(message); outboundEnvelope.setProgress(progress); - channelRegistry.send(outboundEnvelope); + DeliveryResult deliveryResult = channelRegistry.send(outboundEnvelope); + recordDeliveryResult(runId, deliveryResult); + applyDeliveryResult(result, deliveryResult); runtimeStoreService.appendRunEvent(runId, progress ? "notify_progress" : "notify", message); - result.setDelivered(true); - result.setMessage("sent to " + replyTarget.getChannelType() + ":" + replyTarget.getConversationId()); + result.setDelivered(deliveryResult.isDelivered()); + if (StrUtil.isBlank(result.getMessage())) { + result.setMessage("sent to " + replyTarget.getChannelType() + ":" + replyTarget.getConversationId()); + } return result; }; } @@ -258,14 +264,53 @@ public class IsolatedAgentRunService { if (request.getDeliveryMode() != JobDeliveryMode.NONE) { ReplyTarget replyTarget = resolveReplyTarget(request.getDeliveryMode(), request.getBoundReplyTarget()); - if (replyTarget != null && !replyTarget.isDebugWeb()) { + if (replyTarget != null) { OutboundEnvelope outboundEnvelope = new OutboundEnvelope(); outboundEnvelope.setRunId(runId); outboundEnvelope.setReplyTarget(replyTarget); outboundEnvelope.setContent(fallback); - channelRegistry.send(outboundEnvelope); + DeliveryResult deliveryResult = channelRegistry.send(outboundEnvelope); + recordDeliveryResult(runId, deliveryResult); runtimeStoreService.appendRunEvent(runId, "delivery_fallback_sent", fallback); } } } + + private void applyDeliveryResult(NotificationResult result, DeliveryResult deliveryResult) { + if (result == null || deliveryResult == null) { + return; + } + result.setTruncated(deliveryResult.isTruncated()); + result.setSegmented(deliveryResult.isSegmented()); + result.setSegmentCount(deliveryResult.getSegmentCount()); + result.setOriginalLength(deliveryResult.getOriginalLength()); + result.setFinalLength(deliveryResult.getFinalLength()); + result.setChannelType(deliveryResult.getChannelType() == null ? null : deliveryResult.getChannelType().name()); + result.setMessage(deliveryResult.getMessage()); + } + + private void recordDeliveryResult(String runId, DeliveryResult deliveryResult) { + if (deliveryResult == null) { + return; + } + StringBuilder message = new StringBuilder(); + message.append("channel=").append(deliveryResult.getChannelType()) + .append(", segmentCount=").append(deliveryResult.getSegmentCount()) + .append(", originalLength=").append(deliveryResult.getOriginalLength()) + .append(", finalLength=").append(deliveryResult.getFinalLength()); + if (StrUtil.isNotBlank(deliveryResult.getMessage())) { + message.append(", detail=").append(deliveryResult.getMessage()); + } + if (!deliveryResult.isDelivered()) { + runtimeStoreService.appendRunEvent(runId, "delivery_failed", message.toString()); + return; + } + runtimeStoreService.appendRunEvent(runId, "delivery_sent", message.toString()); + if (deliveryResult.isSegmented()) { + runtimeStoreService.appendRunEvent(runId, "delivery_segmented", message.toString()); + } + if (deliveryResult.isTruncated()) { + runtimeStoreService.appendRunEvent(runId, "delivery_truncated", message.toString()); + } + } } diff --git a/src/main/java/com/jimuqu/claw/agent/runtime/impl/SystemEventRunner.java b/src/main/java/com/jimuqu/claw/agent/runtime/impl/SystemEventRunner.java index 7d03a9c..be2a473 100644 --- a/src/main/java/com/jimuqu/claw/agent/runtime/impl/SystemEventRunner.java +++ b/src/main/java/com/jimuqu/claw/agent/runtime/impl/SystemEventRunner.java @@ -12,6 +12,7 @@ import com.jimuqu.claw.agent.runtime.api.ConversationAgent; import com.jimuqu.claw.agent.runtime.api.NotificationSupport; import com.jimuqu.claw.agent.runtime.api.RunQuerySupport; import com.jimuqu.claw.agent.runtime.support.ConversationExecutionRequest; +import com.jimuqu.claw.agent.runtime.support.DeliveryResult; import com.jimuqu.claw.agent.runtime.support.NotificationResult; import com.jimuqu.claw.agent.runtime.support.ParentRunChildrenSummary; import com.jimuqu.claw.agent.runtime.support.SystemEventRequest; @@ -227,7 +228,7 @@ public class SystemEventRunner { if (notifyUsed || isNoReply(response) || StrUtil.isBlank(visibleResponse)) { return false; } - if (request.getReplyTarget() == null || request.getReplyTarget().isDebugWeb()) { + if (request.getReplyTarget() == null) { runtimeStoreService.appendRunEvent(runId, "delivery_suppressed", visibleResponse); return false; } @@ -236,9 +237,10 @@ public class SystemEventRunner { outboundEnvelope.setRunId(runId); outboundEnvelope.setReplyTarget(request.getReplyTarget()); outboundEnvelope.setContent(visibleResponse); - channelRegistry.send(outboundEnvelope); + DeliveryResult deliveryResult = channelRegistry.send(outboundEnvelope); + recordDeliveryResult(runId, deliveryResult); runtimeStoreService.appendRunEvent(runId, "delivery_fallback_sent", visibleResponse); - return true; + return deliveryResult.isDelivered(); } private boolean tryDeliverAggregateReply( @@ -265,7 +267,7 @@ public class SystemEventRunner { runtimeStoreService.appendRunEvent(runId, "delivery_suppressed", "children_not_completed"); return false; } - if (request.getReplyTarget() == null || request.getReplyTarget().isDebugWeb()) { + if (request.getReplyTarget() == null) { runtimeStoreService.appendRunEvent(runId, "delivery_suppressed", "missing_reply_target"); return false; } @@ -274,10 +276,11 @@ public class SystemEventRunner { outboundEnvelope.setRunId(runId); outboundEnvelope.setReplyTarget(request.getReplyTarget()); outboundEnvelope.setContent(visibleResponse); - channelRegistry.send(outboundEnvelope); + DeliveryResult deliveryResult = channelRegistry.send(outboundEnvelope); + recordDeliveryResult(runId, deliveryResult); runtimeStoreService.appendRunEvent(request.getRelatedRunId(), "children_aggregated", "aggregateRunId=" + runId); runtimeStoreService.appendRunEvent(runId, "delivery_fallback_sent", visibleResponse); - return true; + return deliveryResult.isDelivered(); } private NotificationSupport buildNotificationSupport(String sessionKey, ReplyTarget replyTarget, String runId) { @@ -290,7 +293,7 @@ public class SystemEventRunner { result.setMessage("message 不能为空"); return result; } - if (replyTarget == null || replyTarget.isDebugWeb()) { + if (replyTarget == null) { result.setDelivered(false); result.setMessage("当前系统事件没有可用的 ReplyTarget"); return result; @@ -301,11 +304,15 @@ public class SystemEventRunner { outboundEnvelope.setReplyTarget(replyTarget); outboundEnvelope.setContent(message); outboundEnvelope.setProgress(progress); - channelRegistry.send(outboundEnvelope); + DeliveryResult deliveryResult = channelRegistry.send(outboundEnvelope); + recordDeliveryResult(runId, deliveryResult); + applyDeliveryResult(result, deliveryResult); runtimeStoreService.appendRunEvent(runId, progress ? "notify_progress" : "notify", message); - result.setDelivered(true); - result.setMessage("sent to " + replyTarget.getChannelType() + ":" + replyTarget.getConversationId()); + result.setDelivered(deliveryResult.isDelivered()); + if (StrUtil.isBlank(result.getMessage())) { + result.setMessage("sent to " + replyTarget.getChannelType() + ":" + replyTarget.getConversationId()); + } return result; }; } @@ -410,4 +417,42 @@ public class SystemEventRunner { copy.setWakeImmediately(request.isWakeImmediately()); return copy; } + + private void applyDeliveryResult(NotificationResult result, DeliveryResult deliveryResult) { + if (result == null || deliveryResult == null) { + return; + } + result.setTruncated(deliveryResult.isTruncated()); + result.setSegmented(deliveryResult.isSegmented()); + result.setSegmentCount(deliveryResult.getSegmentCount()); + result.setOriginalLength(deliveryResult.getOriginalLength()); + result.setFinalLength(deliveryResult.getFinalLength()); + result.setChannelType(deliveryResult.getChannelType() == null ? null : deliveryResult.getChannelType().name()); + result.setMessage(deliveryResult.getMessage()); + } + + private void recordDeliveryResult(String runId, DeliveryResult deliveryResult) { + if (deliveryResult == null) { + return; + } + StringBuilder message = new StringBuilder(); + message.append("channel=").append(deliveryResult.getChannelType()) + .append(", segmentCount=").append(deliveryResult.getSegmentCount()) + .append(", originalLength=").append(deliveryResult.getOriginalLength()) + .append(", finalLength=").append(deliveryResult.getFinalLength()); + if (StrUtil.isNotBlank(deliveryResult.getMessage())) { + message.append(", detail=").append(deliveryResult.getMessage()); + } + if (!deliveryResult.isDelivered()) { + runtimeStoreService.appendRunEvent(runId, "delivery_failed", message.toString()); + return; + } + runtimeStoreService.appendRunEvent(runId, "delivery_sent", message.toString()); + if (deliveryResult.isSegmented()) { + runtimeStoreService.appendRunEvent(runId, "delivery_segmented", message.toString()); + } + if (deliveryResult.isTruncated()) { + runtimeStoreService.appendRunEvent(runId, "delivery_truncated", message.toString()); + } + } } diff --git a/src/main/java/com/jimuqu/claw/agent/runtime/support/DeliveryResult.java b/src/main/java/com/jimuqu/claw/agent/runtime/support/DeliveryResult.java new file mode 100644 index 0000000..6c9e2ec --- /dev/null +++ b/src/main/java/com/jimuqu/claw/agent/runtime/support/DeliveryResult.java @@ -0,0 +1,33 @@ +package com.jimuqu.claw.agent.runtime.support; + +import com.jimuqu.claw.agent.model.enums.ChannelType; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +/** + * 描述一次出站发送结果。 + */ +@Data +@NoArgsConstructor +public class DeliveryResult implements Serializable { + private static final long serialVersionUID = 1L; + + /** 是否成功发送。 */ + private boolean delivered; + /** 是否发生截断。 */ + private boolean truncated; + /** 是否按分隔符拆成多段发送。 */ + private boolean segmented; + /** 实际发送段数。 */ + private int segmentCount; + /** 原始文本长度。 */ + private int originalLength; + /** 实际发出的总文本长度。 */ + private int finalLength; + /** 渠道类型。 */ + private ChannelType channelType; + /** 结果说明。 */ + private String message; +} diff --git a/src/main/java/com/jimuqu/claw/agent/runtime/support/NotificationResult.java b/src/main/java/com/jimuqu/claw/agent/runtime/support/NotificationResult.java index e95c5c8..166bf2a 100644 --- a/src/main/java/com/jimuqu/claw/agent/runtime/support/NotificationResult.java +++ b/src/main/java/com/jimuqu/claw/agent/runtime/support/NotificationResult.java @@ -19,4 +19,16 @@ public class NotificationResult implements Serializable { private String sessionKey; /** 结果说明。 */ private String message; + /** 是否发生截断。 */ + private boolean truncated; + /** 是否按多段发送。 */ + private boolean segmented; + /** 发送段数。 */ + private int segmentCount; + /** 原始文本长度。 */ + private int originalLength; + /** 实际发出文本总长度。 */ + private int finalLength; + /** 外发渠道类型。 */ + private String channelType; } 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 bcf8358..4ad73f9 100644 --- a/src/main/java/com/jimuqu/claw/agent/store/RuntimeStoreService.java +++ b/src/main/java/com/jimuqu/claw/agent/store/RuntimeStoreService.java @@ -709,10 +709,6 @@ public class RuntimeStoreService { sessionLock.unlock(); } - if (replyTarget.isDebugWeb()) { - return; - } - File file = latestReplyTargetFile(); ReentrantLock lock = lock(file); lock.lock(); diff --git a/src/main/java/com/jimuqu/claw/channel/dingtalk/adapter/DingTalkChannelAdapter.java b/src/main/java/com/jimuqu/claw/channel/dingtalk/adapter/DingTalkChannelAdapter.java index 2e053c9..f70e964 100644 --- a/src/main/java/com/jimuqu/claw/channel/dingtalk/adapter/DingTalkChannelAdapter.java +++ b/src/main/java/com/jimuqu/claw/channel/dingtalk/adapter/DingTalkChannelAdapter.java @@ -16,6 +16,7 @@ import com.jimuqu.claw.agent.model.envelope.InboundEnvelope; import com.jimuqu.claw.agent.model.envelope.OutboundEnvelope; import com.jimuqu.claw.agent.model.route.ReplyTarget; import com.jimuqu.claw.agent.runtime.impl.AgentRuntimeService; +import com.jimuqu.claw.agent.runtime.support.DeliveryResult; import com.jimuqu.claw.channel.dingtalk.sender.DingTalkRobotSender; import com.jimuqu.claw.config.props.DingTalkProperties; import org.slf4j.Logger; @@ -112,12 +113,16 @@ public class DingTalkChannelAdapter implements * @param outboundEnvelope 出站消息 */ @Override - public void send(OutboundEnvelope outboundEnvelope) { + public DeliveryResult send(OutboundEnvelope outboundEnvelope) { if (outboundEnvelope == null || outboundEnvelope.getReplyTarget() == null) { - return; + DeliveryResult result = new DeliveryResult(); + result.setDelivered(false); + result.setMessage("missing reply target"); + result.setChannelType(ChannelType.DINGTALK); + return result; } - dingTalkRobotSender.sendText(outboundEnvelope.getReplyTarget(), normalizeOutboundContent(outboundEnvelope)); + return dingTalkRobotSender.sendText(outboundEnvelope.getReplyTarget(), normalizeOutboundContent(outboundEnvelope)); } /** diff --git a/src/main/java/com/jimuqu/claw/channel/dingtalk/sender/DingTalkRobotSender.java b/src/main/java/com/jimuqu/claw/channel/dingtalk/sender/DingTalkRobotSender.java index 76db37d..05f33bc 100644 --- a/src/main/java/com/jimuqu/claw/channel/dingtalk/sender/DingTalkRobotSender.java +++ b/src/main/java/com/jimuqu/claw/channel/dingtalk/sender/DingTalkRobotSender.java @@ -11,6 +11,7 @@ import com.aliyun.dingtalkrobot_1_0.models.OrgGroupSendRequest; import com.aliyun.teautil.models.RuntimeOptions; import com.jimuqu.claw.agent.model.enums.ConversationType; import com.jimuqu.claw.agent.model.route.ReplyTarget; +import com.jimuqu.claw.agent.runtime.support.DeliveryResult; import com.jimuqu.claw.channel.dingtalk.service.DingTalkAccessTokenService; import com.jimuqu.claw.config.props.DingTalkProperties; import org.slf4j.Logger; @@ -23,8 +24,8 @@ import java.util.regex.Pattern; * 基于钉钉机器人 OpenAPI 发送群聊和私聊消息。 */ public class DingTalkRobotSender { - private static final Pattern MARKDOWN_PREFIX = Pattern.compile("^[#>*`\\-\\s]+"); private static final int MAX_MARKDOWN_TEXT_LENGTH = 5000; + private static final Pattern MARKDOWN_PREFIX = Pattern.compile("^[#>*`\\-\\s]+"); private static final String TRUNCATED_SUFFIX = "\n\n[消息过长,已截断]"; /** 日志记录器。 */ private static final Logger log = LoggerFactory.getLogger(DingTalkRobotSender.class); @@ -72,20 +73,32 @@ public class DingTalkRobotSender { * @param replyTarget 回复目标 * @param content 文本内容 */ - public void sendText(ReplyTarget replyTarget, String content) { + public DeliveryResult sendText(ReplyTarget replyTarget, String content) { + DeliveryResult result = new DeliveryResult(); + result.setChannelType(com.jimuqu.claw.agent.model.enums.ChannelType.DINGTALK); + result.setOriginalLength(content == null ? 0 : content.length()); String normalizedContent = normalizeMarkdownContent(content); if (replyTarget == null || StrUtil.isBlank(normalizedContent)) { - return; + result.setDelivered(false); + result.setFinalLength(normalizedContent == null ? 0 : normalizedContent.length()); + result.setMessage("empty content or missing replyTarget"); + return result; } if (!accessTokenService.isReady()) { log.warn("Skip DingTalk send because access token is not ready."); - return; + result.setDelivered(false); + result.setFinalLength(normalizedContent.length()); + result.setMessage("access token is not ready"); + return result; } if (StrUtil.isBlank(properties.getRobotCode())) { log.warn("Skip DingTalk send because robotCode is missing."); - return; + result.setDelivered(false); + result.setFinalLength(normalizedContent.length()); + result.setMessage("robotCode is missing"); + return result; } try { @@ -95,6 +108,7 @@ public class DingTalkRobotSender { content == null ? 0 : content.length(), normalizedContent.length() ); + result.setTruncated(true); } if (replyTarget.getConversationType() == ConversationType.GROUP) { @@ -102,9 +116,16 @@ public class DingTalkRobotSender { } else { sendPrivate(replyTarget, normalizedContent); } + result.setDelivered(true); + result.setFinalLength(normalizedContent.length()); + result.setMessage("sent"); } catch (Exception exception) { log.warn("Failed to send DingTalk message: {}", exception.getMessage(), exception); + result.setDelivered(false); + result.setFinalLength(normalizedContent.length()); + result.setMessage(exception.getMessage()); } + return result; } /** diff --git a/src/main/java/com/jimuqu/claw/channel/feishu/adapter/FeishuChannelAdapter.java b/src/main/java/com/jimuqu/claw/channel/feishu/adapter/FeishuChannelAdapter.java index 0fbf755..08fd065 100644 --- a/src/main/java/com/jimuqu/claw/channel/feishu/adapter/FeishuChannelAdapter.java +++ b/src/main/java/com/jimuqu/claw/channel/feishu/adapter/FeishuChannelAdapter.java @@ -18,6 +18,7 @@ import com.jimuqu.claw.agent.model.envelope.InboundEnvelope; import com.jimuqu.claw.agent.model.envelope.OutboundEnvelope; import com.jimuqu.claw.agent.model.route.ReplyTarget; import com.jimuqu.claw.agent.runtime.impl.AgentRuntimeService; +import com.jimuqu.claw.agent.runtime.support.DeliveryResult; import com.jimuqu.claw.channel.feishu.sender.FeishuBotSender; import com.jimuqu.claw.config.props.FeishuProperties; import org.slf4j.Logger; @@ -124,8 +125,8 @@ public class FeishuChannelAdapter implements ChannelAdapter { } @Override - public void send(OutboundEnvelope outboundEnvelope) { - feishuBotSender.send(outboundEnvelope); + public DeliveryResult send(OutboundEnvelope outboundEnvelope) { + return feishuBotSender.send(outboundEnvelope); } /** diff --git a/src/main/java/com/jimuqu/claw/channel/feishu/sender/FeishuBotSender.java b/src/main/java/com/jimuqu/claw/channel/feishu/sender/FeishuBotSender.java index 7dc7560..6741c6e 100644 --- a/src/main/java/com/jimuqu/claw/channel/feishu/sender/FeishuBotSender.java +++ b/src/main/java/com/jimuqu/claw/channel/feishu/sender/FeishuBotSender.java @@ -4,7 +4,9 @@ import cn.hutool.core.util.StrUtil; import com.alibaba.fastjson.JSONArray; import com.alibaba.fastjson.JSONObject; import com.jimuqu.claw.agent.model.envelope.OutboundEnvelope; +import com.jimuqu.claw.agent.model.enums.ChannelType; import com.jimuqu.claw.agent.model.route.ReplyTarget; +import com.jimuqu.claw.agent.runtime.support.DeliveryResult; import com.jimuqu.claw.channel.feishu.gateway.FeishuMessageGateway; import com.jimuqu.claw.channel.feishu.gateway.FeishuSdkMessageGateway; import com.jimuqu.claw.config.props.FeishuProperties; @@ -52,20 +54,31 @@ public class FeishuBotSender { * * @param outboundEnvelope 出站消息 */ - public void send(OutboundEnvelope outboundEnvelope) { + public DeliveryResult send(OutboundEnvelope outboundEnvelope) { + DeliveryResult result = new DeliveryResult(); + result.setChannelType(ChannelType.FEISHU); + result.setOriginalLength(outboundEnvelope == null || outboundEnvelope.getContent() == null ? 0 : outboundEnvelope.getContent().length()); if (outboundEnvelope == null || outboundEnvelope.getReplyTarget() == null) { - return; + result.setDelivered(false); + result.setMessage("missing reply target"); + return result; } String content = normalizeContent(outboundEnvelope); if (StrUtil.isBlank(content)) { - return; + result.setDelivered(false); + result.setFinalLength(content == null ? 0 : content.length()); + result.setMessage("empty content"); + return result; } ReplyTarget replyTarget = outboundEnvelope.getReplyTarget(); if (StrUtil.isBlank(replyTarget.getConversationId())) { log.warn("Skip Feishu send because conversationId is missing."); - return; + result.setDelivered(false); + result.setFinalLength(content.length()); + result.setMessage("conversationId is missing"); + return result; } try { @@ -73,12 +86,22 @@ public class FeishuBotSender { String cardContent = cardMessageParam(content); if (outboundEnvelope.isProgress() && properties.isStreamingReply()) { sendProgress(runId, replyTarget, cardContent); - return; + result.setDelivered(true); + result.setFinalLength(content.length()); + result.setMessage("streaming progress sent"); + return result; } sendFinal(runId, replyTarget, cardContent); + result.setDelivered(true); + result.setFinalLength(content.length()); + result.setMessage("sent"); } catch (Exception exception) { log.warn("Failed to send Feishu message: {}", exception.getMessage(), exception); + result.setDelivered(false); + result.setFinalLength(content.length()); + result.setMessage(exception.getMessage()); } + return result; } /** @@ -176,6 +199,7 @@ public class FeishuBotSender { } return builder.toString().trim(); } + } diff --git a/src/main/java/com/jimuqu/claw/web/controller/DebugChatController.java b/src/main/java/com/jimuqu/claw/web/controller/DebugChatController.java index 4bfbd6e..7a7444d 100644 --- a/src/main/java/com/jimuqu/claw/web/controller/DebugChatController.java +++ b/src/main/java/com/jimuqu/claw/web/controller/DebugChatController.java @@ -2,6 +2,11 @@ package com.jimuqu.claw.web.controller; import cn.hutool.core.util.StrUtil; import cn.hutool.json.JSONUtil; +import com.jimuqu.claw.agent.model.envelope.InboundEnvelope; +import com.jimuqu.claw.agent.model.enums.ChannelType; +import com.jimuqu.claw.agent.model.enums.ConversationType; +import com.jimuqu.claw.agent.model.enums.RuntimeSourceKind; +import com.jimuqu.claw.agent.model.route.ReplyTarget; import com.jimuqu.claw.agent.model.run.AgentRun; import com.jimuqu.claw.agent.model.event.RunEvent; import com.jimuqu.claw.agent.runtime.impl.AgentRuntimeService; @@ -50,7 +55,19 @@ public class DebugChatController { sessionId = "default"; } - String runId = agentRuntimeService.submitDebugMessage(sessionId, request.getMessage()); + InboundEnvelope inboundEnvelope = new InboundEnvelope(); + inboundEnvelope.setMessageId("debug-" + java.util.UUID.randomUUID().toString().replace("-", "")); + inboundEnvelope.setChannelType(ChannelType.DEBUG_WEB); + inboundEnvelope.setChannelInstanceId("debug-web"); + inboundEnvelope.setSenderId("debug-user"); + inboundEnvelope.setConversationId(sessionId); + inboundEnvelope.setConversationType(ConversationType.PRIVATE); + inboundEnvelope.setContent(request.getMessage()); + inboundEnvelope.setReceivedAt(System.currentTimeMillis()); + inboundEnvelope.setSessionKey("debug-web:" + sessionId); + inboundEnvelope.setReplyTarget(new ReplyTarget(ChannelType.DEBUG_WEB, ConversationType.PRIVATE, sessionId, "debug-user")); + inboundEnvelope.setSourceKind(RuntimeSourceKind.USER_MESSAGE); + String runId = agentRuntimeService.submitInbound(inboundEnvelope); return new DebugChatResponse(runId, "debug-web:" + sessionId, "queued"); } diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html index 9fd942b..cb77808 100644 --- a/src/main/resources/static/index.html +++ b/src/main/resources/static/index.html @@ -115,7 +115,7 @@

SolonClaw Debug Chat

-

用于本地验证 AI 对话、流式进度和最终回复。默认走 debug-web 渠道,不会和钉钉串会话。

+

用于本地验证 AI 对话、流式进度和最终回复。默认走 debug-web 渠道,并按真实用户消息链路处理。

diff --git a/src/test/java/com/jimuqu/claw/agent/job/WorkspaceJobServiceTest.java b/src/test/java/com/jimuqu/claw/agent/job/WorkspaceJobServiceTest.java index bad7d8a..1c2c308 100644 --- a/src/test/java/com/jimuqu/claw/agent/job/WorkspaceJobServiceTest.java +++ b/src/test/java/com/jimuqu/claw/agent/job/WorkspaceJobServiceTest.java @@ -192,8 +192,20 @@ class WorkspaceJobServiceTest { ConversationAgent noopAgent = (request, progressConsumer) -> "noop"; ChannelRegistry registry = new ChannelRegistry(); - this.systemEventRunner = new CapturingSystemEventRunner(noopAgent, runtimeStoreService, scheduler, registry, properties); - this.isolatedAgentRunService = new CapturingIsolatedAgentRunService(noopAgent, runtimeStoreService, scheduler, registry, properties); + this.systemEventRunner = new CapturingSystemEventRunner( + noopAgent, + runtimeStoreService, + scheduler, + registry, + properties + ); + this.isolatedAgentRunService = new CapturingIsolatedAgentRunService( + noopAgent, + runtimeStoreService, + scheduler, + registry, + properties + ); this.workspaceJobService = new WorkspaceJobService( jobManager, jobStoreService, @@ -215,7 +227,13 @@ class WorkspaceJobServiceTest { ChannelRegistry channelRegistry, SolonClawProperties properties ) { - super(conversationAgent, runtimeStoreService, conversationScheduler, channelRegistry, properties); + super( + conversationAgent, + runtimeStoreService, + conversationScheduler, + channelRegistry, + properties + ); } @Override @@ -235,7 +253,13 @@ class WorkspaceJobServiceTest { ChannelRegistry channelRegistry, SolonClawProperties properties ) { - super(conversationAgent, runtimeStoreService, conversationScheduler, channelRegistry, properties); + super( + conversationAgent, + runtimeStoreService, + conversationScheduler, + channelRegistry, + properties + ); } @Override 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 0025da4..39ee74a 100644 --- a/src/test/java/com/jimuqu/claw/agent/runtime/AgentRuntimeServiceTest.java +++ b/src/test/java/com/jimuqu/claw/agent/runtime/AgentRuntimeServiceTest.java @@ -16,6 +16,7 @@ import com.jimuqu.claw.agent.runtime.impl.AgentRuntimeService; import com.jimuqu.claw.agent.runtime.impl.ConversationScheduler; import com.jimuqu.claw.agent.runtime.impl.SystemEventRunner; import com.jimuqu.claw.agent.runtime.support.ConversationExecutionRequest; +import com.jimuqu.claw.agent.runtime.support.DeliveryResult; import com.jimuqu.claw.agent.runtime.support.NotificationResult; import com.jimuqu.claw.agent.runtime.support.ParentRunChildrenSummary; import com.jimuqu.claw.agent.store.RuntimeStoreService; @@ -160,10 +161,12 @@ class AgentRuntimeServiceTest { } @Test - void debugMessageIsStillUserMessageAndDoesNotRememberExternalRoute() throws Exception { + void debugWebInboundIsHandledLikeNormalUserMessage() throws Exception { RuntimeStoreService store = new RuntimeStoreService(tempDir.toFile()); ConversationScheduler scheduler = new ConversationScheduler(1); ChannelRegistry registry = new ChannelRegistry(); + RecordingChannelAdapter debugAdapter = new RecordingChannelAdapter(ChannelType.DEBUG_WEB); + registry.register(debugAdapter); AtomicReference lastRequest = new AtomicReference(); ConversationAgent conversationAgent = (request, progressConsumer) -> { lastRequest.set(request); @@ -173,14 +176,29 @@ class AgentRuntimeServiceTest { AgentRuntimeService runtimeService = runtimeService(conversationAgent, store, scheduler, registry, properties); try { - String runId = runtimeService.submitDebugMessage("debug-1", "hello"); + InboundEnvelope inboundEnvelope = new InboundEnvelope(); + inboundEnvelope.setMessageId("debug-1"); + inboundEnvelope.setChannelType(ChannelType.DEBUG_WEB); + inboundEnvelope.setChannelInstanceId("debug-web"); + inboundEnvelope.setSenderId("debug-user"); + inboundEnvelope.setConversationId("debug-1"); + inboundEnvelope.setConversationType(ConversationType.PRIVATE); + inboundEnvelope.setContent("hello"); + inboundEnvelope.setReplyTarget(new ReplyTarget(ChannelType.DEBUG_WEB, ConversationType.PRIVATE, "debug-1", "debug-user")); + inboundEnvelope.setReceivedAt(System.currentTimeMillis()); + inboundEnvelope.setSessionKey("debug-web:debug-1"); + inboundEnvelope.setSourceKind(RuntimeSourceKind.USER_MESSAGE); + + String runId = runtimeService.submitInbound(inboundEnvelope); assertNotNull(runId); assertTrue(waitUntil(() -> { AgentRun run = runtimeService.getRun(runId); return run != null && run.getStatus() == RunStatus.SUCCEEDED; }, 5000)); assertEquals(RuntimeSourceKind.USER_MESSAGE, lastRequest.get().getCurrentSourceKind()); - assertNull(store.getLatestExternalRoute()); + assertNotNull(store.getLatestExternalRoute()); + assertEquals(ChannelType.DEBUG_WEB, store.getLatestExternalRoute().getReplyTarget().getChannelType()); + assertEquals("debug-reply", debugAdapter.messages.get(0)); } finally { scheduler.shutdown(); } @@ -235,8 +253,21 @@ class AgentRuntimeServiceTest { ChannelRegistry registry, SolonClawProperties properties ) { - SystemEventRunner systemEventRunner = new SystemEventRunner(conversationAgent, store, scheduler, registry, properties); - return new AgentRuntimeService(conversationAgent, store, scheduler, registry, systemEventRunner, properties); + SystemEventRunner systemEventRunner = new SystemEventRunner( + conversationAgent, + store, + scheduler, + registry, + properties + ); + return new AgentRuntimeService( + conversationAgent, + store, + scheduler, + registry, + systemEventRunner, + properties + ); } private InboundEnvelope inbound(String messageId, String content) { @@ -269,18 +300,34 @@ class AgentRuntimeServiceTest { } private static class RecordingChannelAdapter implements ChannelAdapter { + private final ChannelType channelType; protected final List messages = new CopyOnWriteArrayList(); protected final List outbounds = new CopyOnWriteArrayList(); + private RecordingChannelAdapter() { + this(ChannelType.DINGTALK); + } + + private RecordingChannelAdapter(ChannelType channelType) { + this.channelType = channelType; + } + @Override public ChannelType channelType() { - return ChannelType.DINGTALK; + return channelType; } @Override - public void send(OutboundEnvelope outboundEnvelope) { + public DeliveryResult send(OutboundEnvelope outboundEnvelope) { outbounds.add(outboundEnvelope); messages.add(outboundEnvelope.getContent()); + DeliveryResult result = new DeliveryResult(); + result.setDelivered(true); + result.setChannelType(channelType()); + result.setOriginalLength(outboundEnvelope.getContent() == null ? 0 : outboundEnvelope.getContent().length()); + result.setFinalLength(result.getOriginalLength()); + result.setMessage("sent"); + return result; } } diff --git a/src/test/java/com/jimuqu/claw/agent/runtime/IsolatedAgentRunServiceTest.java b/src/test/java/com/jimuqu/claw/agent/runtime/IsolatedAgentRunServiceTest.java index 5a91fa1..87c15e7 100644 --- a/src/test/java/com/jimuqu/claw/agent/runtime/IsolatedAgentRunServiceTest.java +++ b/src/test/java/com/jimuqu/claw/agent/runtime/IsolatedAgentRunServiceTest.java @@ -14,6 +14,7 @@ import com.jimuqu.claw.agent.runtime.api.ConversationAgent; import com.jimuqu.claw.agent.runtime.impl.ConversationScheduler; import com.jimuqu.claw.agent.runtime.impl.IsolatedAgentRunService; import com.jimuqu.claw.agent.runtime.support.AgentTurnRequest; +import com.jimuqu.claw.agent.runtime.support.DeliveryResult; import com.jimuqu.claw.agent.store.RuntimeStoreService; import com.jimuqu.claw.config.SolonClawProperties; import org.junit.jupiter.api.Test; @@ -105,6 +106,7 @@ class IsolatedAgentRunServiceTest { private AgentTurnRequest request(JobDeliveryMode deliveryMode, ReplyTarget boundReplyTarget) { AgentTurnRequest request = new AgentTurnRequest(); + request.setSourceKind(com.jimuqu.claw.agent.model.enums.RuntimeSourceKind.JOB_AGENT_TURN); request.setJobName("agent-job"); request.setBoundSessionKey("dingtalk:group:group-1"); request.setBoundReplyTarget(boundReplyTarget); @@ -136,9 +138,16 @@ class IsolatedAgentRunServiceTest { } @Override - public void send(OutboundEnvelope outboundEnvelope) { + public DeliveryResult send(OutboundEnvelope outboundEnvelope) { outbounds.add(outboundEnvelope); messages.add(outboundEnvelope.getContent()); + DeliveryResult result = new DeliveryResult(); + result.setDelivered(true); + result.setChannelType(channelType()); + result.setOriginalLength(outboundEnvelope.getContent() == null ? 0 : outboundEnvelope.getContent().length()); + result.setFinalLength(result.getOriginalLength()); + result.setMessage("sent"); + return result; } } } diff --git a/src/test/java/com/jimuqu/claw/agent/runtime/SystemEventRunnerTest.java b/src/test/java/com/jimuqu/claw/agent/runtime/SystemEventRunnerTest.java index 77860c4..6e87ee5 100644 --- a/src/test/java/com/jimuqu/claw/agent/runtime/SystemEventRunnerTest.java +++ b/src/test/java/com/jimuqu/claw/agent/runtime/SystemEventRunnerTest.java @@ -13,6 +13,7 @@ import com.jimuqu.claw.agent.model.run.AgentRun; import com.jimuqu.claw.agent.runtime.api.ConversationAgent; import com.jimuqu.claw.agent.runtime.impl.ConversationScheduler; import com.jimuqu.claw.agent.runtime.impl.SystemEventRunner; +import com.jimuqu.claw.agent.runtime.support.DeliveryResult; import com.jimuqu.claw.agent.runtime.support.NotificationResult; import com.jimuqu.claw.agent.runtime.support.SystemEventRequest; import com.jimuqu.claw.agent.store.RuntimeStoreService; @@ -202,9 +203,16 @@ class SystemEventRunnerTest { } @Override - public void send(OutboundEnvelope outboundEnvelope) { + public DeliveryResult send(OutboundEnvelope outboundEnvelope) { outbounds.add(outboundEnvelope); messages.add(outboundEnvelope.getContent()); + DeliveryResult result = new DeliveryResult(); + result.setDelivered(true); + result.setChannelType(channelType()); + result.setOriginalLength(outboundEnvelope.getContent() == null ? 0 : outboundEnvelope.getContent().length()); + result.setFinalLength(result.getOriginalLength()); + result.setMessage("sent"); + return result; } } } diff --git a/src/test/java/com/jimuqu/claw/channel/feishu/FeishuBotSenderTest.java b/src/test/java/com/jimuqu/claw/channel/feishu/FeishuBotSenderTest.java index b7131e4..7f152ca 100644 --- a/src/test/java/com/jimuqu/claw/channel/feishu/FeishuBotSenderTest.java +++ b/src/test/java/com/jimuqu/claw/channel/feishu/FeishuBotSenderTest.java @@ -53,7 +53,6 @@ class FeishuBotSenderTest { 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); -- Gitee From 941801df15c27bb72c9868c9a1d20462ab7ba57e Mon Sep 17 00:00:00 2001 From: chengliang Date: Fri, 20 Mar 2026 16:17:00 +0800 Subject: [PATCH 08/10] =?UTF-8?q?refactor(job):=20=E6=8C=89=20dev=20?= =?UTF-8?q?=E8=AF=AD=E4=B9=89=E6=94=B6=E6=95=9B=E5=AE=9A=E6=97=B6=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E5=B7=A5=E5=85=B7=E4=B8=8E=E6=8F=90=E7=A4=BA=E8=AF=8D?= =?UTF-8?q?=20(Align=20scheduled=20job=20tools=20with=20dev=20semantics)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../jimuqu/claw/agent/job/JobDefinition.java | 9 --- .../claw/agent/job/WorkspaceJobService.java | 78 +------------------ .../com/jimuqu/claw/agent/tool/JobTools.java | 36 ++++----- src/main/resources/template/AGENTS.md | 13 ++-- 4 files changed, 25 insertions(+), 111 deletions(-) diff --git a/src/main/java/com/jimuqu/claw/agent/job/JobDefinition.java b/src/main/java/com/jimuqu/claw/agent/job/JobDefinition.java index 364cb18..938ff66 100644 --- a/src/main/java/com/jimuqu/claw/agent/job/JobDefinition.java +++ b/src/main/java/com/jimuqu/claw/agent/job/JobDefinition.java @@ -28,15 +28,6 @@ public class JobDefinition implements Serializable { private ReplyTarget boundReplyTarget; private String systemEventText; private AgentTurnSpec agentTurn = new AgentTurnSpec(); - /** 兼容旧版 add_job 持久化字段。 */ - @Deprecated - private String prompt; - /** 兼容旧版 add_job 持久化字段。 */ - @Deprecated - private String sessionKey; - /** 兼容旧版 add_job 持久化字段。 */ - @Deprecated - private ReplyTarget replyTarget; private long createdAt; private long updatedAt; } diff --git a/src/main/java/com/jimuqu/claw/agent/job/WorkspaceJobService.java b/src/main/java/com/jimuqu/claw/agent/job/WorkspaceJobService.java index 80c3f13..049f09b 100644 --- a/src/main/java/com/jimuqu/claw/agent/job/WorkspaceJobService.java +++ b/src/main/java/com/jimuqu/claw/agent/job/WorkspaceJobService.java @@ -48,22 +48,16 @@ public class WorkspaceJobService { public void restorePersistedJobs() { for (JobDefinition definition : jobStoreService.loadAll()) { - normalizeDefinition(definition); registerJob(definition); - jobStoreService.save(definition); } } public List listJobs() { - List definitions = jobStoreService.loadAll(); - for (JobDefinition definition : definitions) { - normalizeDefinition(definition); - } - return definitions; + return jobStoreService.loadAll(); } public JobDefinition getJob(String name) { - return normalizeDefinition(jobStoreService.get(name)); + return jobStoreService.get(name); } public JobDefinition addSystemJob( @@ -159,16 +153,6 @@ public class WorkspaceJobService { return definition; } - public JobDefinition addJob(String name, String mode, String scheduleValue, String prompt, long initialDelay, String zone) { - if (StrUtil.isBlank(prompt)) { - throw new IllegalArgumentException("prompt 不能为空"); - } - - AgentTurnSpec agentTurnSpec = new AgentTurnSpec(); - agentTurnSpec.setMessage(prompt.trim()); - return addAgentJob(name, mode, scheduleValue, agentTurnSpec, initialDelay, zone, null); - } - public JobDefinition removeJob(String name) { JobDefinition definition = requireDefinition(name); if (jobManager.jobExists(definition.getName())) { @@ -214,69 +198,13 @@ public class WorkspaceJobService { } private JobDefinition requireDefinition(String name) { - JobDefinition definition = normalizeDefinition(jobStoreService.get(name)); + JobDefinition definition = jobStoreService.get(name); if (definition == null) { throw new IllegalArgumentException("定时任务不存在: " + name); } return definition; } - private JobDefinition normalizeDefinition(JobDefinition definition) { - if (definition == null) { - return null; - } - - boolean legacyPromptJob = definition.getPayloadKind() == null && StrUtil.isNotBlank(definition.getPrompt()); - if (legacyPromptJob) { - definition.setPayloadKind(JobPayloadKind.AGENT_TURN); - definition.setSessionTarget(JobSessionTarget.ISOLATED); - definition.setWakeMode(JobWakeMode.NOW); - definition.setDeliveryMode(JobDeliveryMode.BOUND_REPLY_TARGET); - } - - if (definition.getPayloadKind() == JobPayloadKind.SYSTEM_EVENT) { - if (definition.getSessionTarget() == null) { - definition.setSessionTarget(JobSessionTarget.MAIN); - } - if (definition.getWakeMode() == null) { - definition.setWakeMode(properties.getAgent().getJobs().getDefaultWakeMode()); - } - if (definition.getDeliveryMode() == null) { - definition.setDeliveryMode(JobDeliveryMode.NONE); - } - } - - if (definition.getPayloadKind() == JobPayloadKind.AGENT_TURN) { - if (definition.getSessionTarget() == null) { - definition.setSessionTarget(JobSessionTarget.ISOLATED); - } - if (definition.getWakeMode() == null) { - definition.setWakeMode(JobWakeMode.NOW); - } - if (definition.getDeliveryMode() == null) { - definition.setDeliveryMode(properties.getAgent().getJobs().getDefaultDeliveryMode()); - } - if (definition.getAgentTurn() == null) { - definition.setAgentTurn(new AgentTurnSpec()); - } - if (StrUtil.isBlank(definition.getAgentTurn().getMessage()) && StrUtil.isNotBlank(definition.getPrompt())) { - definition.getAgentTurn().setMessage(definition.getPrompt().trim()); - } - if (definition.getAgentTurn().getTimeoutSeconds() == null) { - definition.getAgentTurn().setTimeoutSeconds(properties.getAgent().getAgentTurn().getDefaultTimeoutSeconds()); - } - } - - if (StrUtil.isBlank(definition.getBoundSessionKey()) && StrUtil.isNotBlank(definition.getSessionKey())) { - definition.setBoundSessionKey(definition.getSessionKey().trim()); - } - if (definition.getBoundReplyTarget() == null && definition.getReplyTarget() != null) { - definition.setBoundReplyTarget(definition.getReplyTarget()); - } - - return definition; - } - private void registerJob(JobDefinition definition) { if (jobManager.jobExists(definition.getName())) { try { diff --git a/src/main/java/com/jimuqu/claw/agent/tool/JobTools.java b/src/main/java/com/jimuqu/claw/agent/tool/JobTools.java index 32fbdcf..9843ce8 100644 --- a/src/main/java/com/jimuqu/claw/agent/tool/JobTools.java +++ b/src/main/java/com/jimuqu/claw/agent/tool/JobTools.java @@ -32,33 +32,19 @@ public class JobTools { } @ToolMapping( - name = "add_job", - description = "新增定时任务。对时间触发型需求,只有实际成功调用本工具后,才能声称“已安排”或“已创建”。" + name = "add_system_job", + description = "新增 systemEvent 定时任务。对时间触发型需求,只有实际成功调用本工具后,才能声称“已安排”或“已创建”。" + "mode 仅支持 fixed_rate、fixed_delay、once_delay、cron;fixed_* 与 once_delay 的 scheduleValue 单位为毫秒。" - + "prompt 必须是未来触发时可独立执行的完整任务指令,要求自包含、可脱离当前对话单独理解," - + "不能只是对当前对话的复述,也不能保留依赖当前语境的省略表达。" + + "systemEventText 必须是未来触发时仍可独立理解的完整内部事件文本," + + "不能只是对当前对话的省略复述,也不能保留依赖当前语境才能理解的指代。" + "创建任务后,当前轮回复只应基于本工具的真实返回结果确认已安排的时间、频率和任务目标," + "不要把未来真正要执行的结果提前在当前轮发送。" ) - public String addJob( - @Param(description = "任务名称。应稳定、清晰,便于后续查询、删除或覆盖") String name, - @Param(description = "调度模式:fixed_rate、fixed_delay、once_delay、cron") String mode, - @Param(description = "调度值:cron 表达式或毫秒值") String scheduleValue, - @Param(description = "未来触发时交给 Agent 执行的完整任务指令。必须自包含、可独立理解,能够直接指导未来那次执行") String prompt, - @Param(description = "首次执行前延迟毫秒数,可填 0。对 once_delay,若大于 0,会优先作为真实延迟") long initialDelay, - @Param(description = "时区,可为空;cron 任务建议显式提供,例如 Asia/Shanghai") String zone - ) { - return JSONUtil.toJsonPrettyStr( - workspaceJobService.addJob(name, mode, scheduleValue, prompt, initialDelay, zone) - ); - } - - @ToolMapping(name = "add_system_job", description = "新增 systemEvent 定时任务。mode 仅支持 fixed_rate、fixed_delay、once_delay、cron;fixed_* 与 once_delay 的 scheduleValue 单位为毫秒") public String addSystemJob( @Param(description = "任务名称") String name, @Param(description = "调度模式:fixed_rate、fixed_delay、once_delay、cron") String mode, @Param(description = "调度值:cron 表达式或毫秒值") String scheduleValue, - @Param(description = "触发时注入主会话的 system event 文本") String systemEventText, + @Param(description = "触发时注入主会话的 system event 文本。必须自包含、可脱离当前对话单独理解") String systemEventText, @Param(description = "首次执行前延迟毫秒数,可填 0") long initialDelay, @Param(description = "时区,可为空") String zone, @Param(description = "唤醒模式:NOW 或 NEXT_TICK,可为空") String wakeMode @@ -68,12 +54,20 @@ public class JobTools { ); } - @ToolMapping(name = "add_agent_job", description = "新增 agentTurn 定时任务。mode 仅支持 fixed_rate、fixed_delay、once_delay、cron;fixed_* 与 once_delay 的 scheduleValue 单位为毫秒") + @ToolMapping( + name = "add_agent_job", + description = "新增 agentTurn 定时任务。对时间触发型需求,只有实际成功调用本工具后,才能声称“已安排”或“已创建”。" + + "mode 仅支持 fixed_rate、fixed_delay、once_delay、cron;fixed_* 与 once_delay 的 scheduleValue 单位为毫秒。" + + "message 必须是未来触发时交给 agent 执行的完整任务指令,要求自包含、可脱离当前对话单独理解," + + "不能只是对当前对话的复述,也不能保留依赖当前语境的省略表达。" + + "创建任务后,当前轮回复只应基于本工具的真实返回结果确认已安排的时间、频率和任务目标," + + "不要把未来真正要执行的结果提前在当前轮发送。" + ) public String addAgentJob( @Param(description = "任务名称") String name, @Param(description = "调度模式:fixed_rate、fixed_delay、once_delay、cron") String mode, @Param(description = "调度值:cron 表达式或毫秒值") String scheduleValue, - @Param(description = "agentTurn 的任务描述") String message, + @Param(description = "agentTurn 的任务描述。必须自包含、可独立理解,能够直接指导未来那次执行") String message, @Param(description = "首次执行前延迟毫秒数,可填 0") long initialDelay, @Param(description = "时区,可为空") String zone, @Param(description = "投递策略:NONE、BOUND_REPLY_TARGET、LAST_ROUTE") String deliveryMode, diff --git a/src/main/resources/template/AGENTS.md b/src/main/resources/template/AGENTS.md index d7a7aaa..7990879 100644 --- a/src/main/resources/template/AGENTS.md +++ b/src/main/resources/template/AGENTS.md @@ -97,12 +97,13 @@ - 如果当前运行没有可靠的“当前时间”来源,就明确说明“无法确认当前时间”,不要自行编造现在几点,也不要基于不确定时间创建任务 - 对相对时间表达,应先基于真实当前时间换算出绝对触发时间,再创建任务 - 对绝对时间表达,必须结合真实当前时间判断这是今天稍后、明天,还是一个已经过去的时间;不要默认把已过期时间当作未来时间 -- 除非用户明确要求你使用其他机制,否则所有时间触发型需求都优先使用 `add_job` -- 对时间触发型需求,是否成功必须以 `add_job` 等定时任务工具实际调用成功为准;只有工具成功返回后,才能对用户声称已创建、已安排、已设置或将会按时执行 -- 如果没有真正调用 `add_job`,或工具调用失败、参数不完整、结果不确定,就必须明确说明“尚未创建成功”以及原因,不能仅凭语言生成一个看似已安排的回复 -- 创建定时任务时,要先把用户当前意图抽象成“未来触发时可独立执行的任务指令”,再作为 `add_job` 的 `prompt` 保存 -- `add_job` 的 `prompt` 必须自包含、可脱离当前对话单独理解;不要把依赖当下语境、代词指代、临时省略、上下文暗示的表达原样保存进去 -- 生成 `prompt` 时,应保留用户真实目标、对象、约束和期望结果,把口语化请求整理成未来可直接执行的任务描述,而不是简单复述用户原话 +- 除非用户明确要求你使用其他机制,否则所有时间触发型需求都优先使用现有定时任务工具:提醒、通知、向主会话注入内部事件时优先使用 `add_system_job`;需要未来自动执行一段独立任务时优先使用 `add_agent_job` +- 对时间触发型需求,是否成功必须以 `add_system_job` / `add_agent_job` 等定时任务工具实际调用成功为准;只有工具成功返回后,才能对用户声称已创建、已安排、已设置或将会按时执行 +- 如果没有真正调用 `add_system_job` 或 `add_agent_job`,或工具调用失败、参数不完整、结果不确定,就必须明确说明“尚未创建成功”以及原因,不能仅凭语言生成一个看似已安排的回复 +- 创建 `add_system_job` 时,要先把用户当前意图抽象成“未来触发时可独立理解的 system event 文本”,再作为 `systemEventText` 保存 +- 创建 `add_agent_job` 时,要先把用户当前意图抽象成“未来触发时可独立执行的任务指令”,再作为 `message` 保存 +- `add_system_job.systemEventText` 和 `add_agent_job.message` 都必须自包含、可脱离当前对话单独理解;不要把依赖当下语境、代词指代、临时省略、上下文暗示的表达原样保存进去 +- 生成未来任务文本时,应保留用户真实目标、对象、约束和期望结果,把口语化请求整理成未来可直接执行的任务描述,而不是简单复述用户原话 - 如果用户目标已经足够明确,直接生成可执行 `prompt`,不要为了形式完整而追问显而易见的信息 - 只有在关键目标、对象或执行前提缺失,并且这种缺失会导致未来任务无法正确执行时,才允许提问补充 - 创建定时任务后,不要在当前运行里再次自行等待并额外通知一次;后续提醒只应由定时任务触发 -- Gitee From dc677cfcbf0697acdb77a86fe57b3ec4234dc4c2 Mon Sep 17 00:00:00 2001 From: chengliang Date: Fri, 20 Mar 2026 16:22:29 +0800 Subject: [PATCH 09/10] =?UTF-8?q?docs(model):=20=E8=A1=A5=E5=85=85?= =?UTF-8?q?=E8=BF=90=E8=A1=8C=E6=97=B6=E6=95=B0=E6=8D=AE=E7=B1=BB=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=E6=B3=A8=E9=87=8A=20(Add=20field=20comments=20for=20r?= =?UTF-8?q?untime=20models)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/jimuqu/claw/agent/job/JobDefinition.java | 16 ++++++++++++++++ .../agent/runtime/support/AgentTurnRequest.java | 6 ++++++ .../runtime/support/SystemEventRequest.java | 9 +++++++++ 3 files changed, 31 insertions(+) diff --git a/src/main/java/com/jimuqu/claw/agent/job/JobDefinition.java b/src/main/java/com/jimuqu/claw/agent/job/JobDefinition.java index 938ff66..9558af3 100644 --- a/src/main/java/com/jimuqu/claw/agent/job/JobDefinition.java +++ b/src/main/java/com/jimuqu/claw/agent/job/JobDefinition.java @@ -14,20 +14,36 @@ import java.io.Serializable; public class JobDefinition implements Serializable { private static final long serialVersionUID = 1L; + /** 任务唯一名称,用于查询、替换、启动和停止。 */ private String name; + /** 调度模式:fixed_rate、fixed_delay、once_delay 或 cron。 */ private String mode; + /** 调度值;固定频率/延迟时为毫秒,cron 模式时为 cron 表达式。 */ private String scheduleValue; + /** 首次执行前的延迟毫秒数。 */ private long initialDelay; + /** 可选时区;cron 任务通常需要结合该字段解释触发时间。 */ private String zone; + /** 任务当前是否启用。 */ private boolean enabled = true; + /** 任务载荷类型,决定这次触发走 system event 还是 agent turn。 */ private JobPayloadKind payloadKind; + /** 任务绑定到主会话还是隔离会话。 */ private JobSessionTarget sessionTarget; + /** system event 任务的唤醒策略。 */ private JobWakeMode wakeMode = JobWakeMode.NOW; + /** agent turn 任务的回传策略。 */ private JobDeliveryMode deliveryMode = JobDeliveryMode.NONE; + /** 任务绑定的主会话 sessionKey。 */ private String boundSessionKey; + /** 任务绑定的回复路由,用于需要对外发送结果的场景。 */ private ReplyTarget boundReplyTarget; + /** system event 任务触发时注入主会话的内部事件文本。 */ private String systemEventText; + /** agent turn 任务触发时实际执行的指令参数。 */ private AgentTurnSpec agentTurn = new AgentTurnSpec(); + /** 任务创建时间戳(毫秒)。 */ private long createdAt; + /** 任务最近更新时间戳(毫秒)。 */ private long updatedAt; } diff --git a/src/main/java/com/jimuqu/claw/agent/runtime/support/AgentTurnRequest.java b/src/main/java/com/jimuqu/claw/agent/runtime/support/AgentTurnRequest.java index 44eb7a1..e21833a 100644 --- a/src/main/java/com/jimuqu/claw/agent/runtime/support/AgentTurnRequest.java +++ b/src/main/java/com/jimuqu/claw/agent/runtime/support/AgentTurnRequest.java @@ -17,10 +17,16 @@ import java.io.Serializable; public class AgentTurnRequest implements Serializable { private static final long serialVersionUID = 1L; + /** 请求来源类型,固定为 JOB_AGENT_TURN。 */ private RuntimeSourceKind sourceKind = RuntimeSourceKind.JOB_AGENT_TURN; + /** 触发此次执行的任务名称。 */ private String jobName; + /** 任务创建时绑定的主会话 sessionKey。 */ private String boundSessionKey; + /** 任务创建时绑定的回复路由。 */ private ReplyTarget boundReplyTarget; + /** 本次执行完成后如何把结果投递回外部会话。 */ private JobDeliveryMode deliveryMode = JobDeliveryMode.NONE; + /** 本次隔离 agent turn 的执行参数。 */ private AgentTurnSpec agentTurn = new AgentTurnSpec(); } diff --git a/src/main/java/com/jimuqu/claw/agent/runtime/support/SystemEventRequest.java b/src/main/java/com/jimuqu/claw/agent/runtime/support/SystemEventRequest.java index a6385b5..fe131aa 100644 --- a/src/main/java/com/jimuqu/claw/agent/runtime/support/SystemEventRequest.java +++ b/src/main/java/com/jimuqu/claw/agent/runtime/support/SystemEventRequest.java @@ -16,13 +16,22 @@ import java.io.Serializable; public class SystemEventRequest implements Serializable { private static final long serialVersionUID = 1L; + /** 请求来源类型,如定时任务、心跳或子任务 continuation。 */ private RuntimeSourceKind sourceKind; + /** 系统事件的对外可见策略。 */ private SystemEventPolicy policy = SystemEventPolicy.INTERNAL_ONLY; + /** 要投递到的目标会话 sessionKey。 */ private String sessionKey; + /** 当前事件可使用的回复路由。 */ private ReplyTarget replyTarget; + /** 本次系统事件注入给 Agent 的文本内容。 */ private String content; + /** 关联的用户消息版本号,用于历史重建和 continuation 对齐。 */ private long sourceUserVersion; + /** 关联运行 ID,常用于父任务 continuation 聚合。 */ private String relatedRunId; + /** 是否允许本次系统事件调用 notify_user 主动通知外部会话。 */ private boolean allowNotifyUser; + /** 是否在提交时立即唤醒执行;否则进入待处理队列。 */ private boolean wakeImmediately = true; } -- Gitee From 1c3eae4ce77e96f4813a72e88871ad83bc9effee Mon Sep 17 00:00:00 2001 From: chengliang Date: Fri, 20 Mar 2026 16:28:09 +0800 Subject: [PATCH 10/10] =?UTF-8?q?docs(model):=20=E7=BB=9F=E4=B8=80=20Data?= =?UTF-8?q?=20=E7=B1=BB=E5=AD=97=E6=AE=B5=E6=B3=A8=E9=87=8A=E9=A3=8E?= =?UTF-8?q?=E6=A0=BC=20(Unify=20field=20comment=20style=20for=20data=20cla?= =?UTF-8?q?sses)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/jimuqu/claw/agent/job/AgentTurnSpec.java | 1 + src/main/java/com/jimuqu/claw/agent/job/JobDefinition.java | 1 + .../java/com/jimuqu/claw/agent/model/envelope/AttachmentRef.java | 1 + .../com/jimuqu/claw/agent/model/envelope/InboundEnvelope.java | 1 + .../com/jimuqu/claw/agent/model/envelope/OutboundEnvelope.java | 1 + .../com/jimuqu/claw/agent/model/event/ChildRunCompletedData.java | 1 + .../com/jimuqu/claw/agent/model/event/ChildRunSpawnedData.java | 1 + .../com/jimuqu/claw/agent/model/event/ConversationEvent.java | 1 + src/main/java/com/jimuqu/claw/agent/model/event/RunEvent.java | 1 + .../java/com/jimuqu/claw/agent/model/route/LatestReplyRoute.java | 1 + src/main/java/com/jimuqu/claw/agent/model/route/ReplyTarget.java | 1 + src/main/java/com/jimuqu/claw/agent/model/run/AgentRun.java | 1 + .../com/jimuqu/claw/agent/runtime/support/AgentTurnRequest.java | 1 + .../claw/agent/runtime/support/ConversationExecutionRequest.java | 1 + .../com/jimuqu/claw/agent/runtime/support/DeliveryResult.java | 1 + .../jimuqu/claw/agent/runtime/support/NotificationResult.java | 1 + .../claw/agent/runtime/support/ParentRunChildrenSummary.java | 1 + .../com/jimuqu/claw/agent/runtime/support/SpawnTaskResult.java | 1 + .../jimuqu/claw/agent/runtime/support/SystemEventRequest.java | 1 + src/main/java/com/jimuqu/claw/config/SolonClawProperties.java | 1 + src/main/java/com/jimuqu/claw/config/props/AgentProperties.java | 1 + .../java/com/jimuqu/claw/config/props/AgentTurnProperties.java | 1 + .../java/com/jimuqu/claw/config/props/BlacklistProperties.java | 1 + .../java/com/jimuqu/claw/config/props/ChannelsProperties.java | 1 + .../java/com/jimuqu/claw/config/props/DingTalkProperties.java | 1 + src/main/java/com/jimuqu/claw/config/props/FeishuProperties.java | 1 + .../java/com/jimuqu/claw/config/props/HeartbeatProperties.java | 1 + src/main/java/com/jimuqu/claw/config/props/JobsProperties.java | 1 + .../java/com/jimuqu/claw/config/props/SchedulerProperties.java | 1 + .../java/com/jimuqu/claw/config/props/SubtasksProperties.java | 1 + .../com/jimuqu/claw/config/props/SystemEventsProperties.java | 1 + src/main/java/com/jimuqu/claw/config/props/ToolsProperties.java | 1 + src/main/java/com/jimuqu/claw/web/dto/DebugChatRequest.java | 1 + src/main/java/com/jimuqu/claw/web/dto/DebugChatResponse.java | 1 + .../java/com/jimuqu/claw/web/dto/DebugChildRunsResponse.java | 1 + .../java/com/jimuqu/claw/web/dto/DebugRunEventsResponse.java | 1 + src/main/java/com/jimuqu/claw/web/dto/DebugRunResponse.java | 1 + 37 files changed, 37 insertions(+) diff --git a/src/main/java/com/jimuqu/claw/agent/job/AgentTurnSpec.java b/src/main/java/com/jimuqu/claw/agent/job/AgentTurnSpec.java index c4df5b9..231394b 100644 --- a/src/main/java/com/jimuqu/claw/agent/job/AgentTurnSpec.java +++ b/src/main/java/com/jimuqu/claw/agent/job/AgentTurnSpec.java @@ -11,6 +11,7 @@ import java.io.Serializable; @Data @NoArgsConstructor public class AgentTurnSpec implements Serializable { + /** 序列化版本号。 */ private static final long serialVersionUID = 1L; /** 任务描述或执行指令。 */ diff --git a/src/main/java/com/jimuqu/claw/agent/job/JobDefinition.java b/src/main/java/com/jimuqu/claw/agent/job/JobDefinition.java index 9558af3..776c0a1 100644 --- a/src/main/java/com/jimuqu/claw/agent/job/JobDefinition.java +++ b/src/main/java/com/jimuqu/claw/agent/job/JobDefinition.java @@ -12,6 +12,7 @@ import java.io.Serializable; @Data @NoArgsConstructor public class JobDefinition implements Serializable { + /** 序列化版本号。 */ private static final long serialVersionUID = 1L; /** 任务唯一名称,用于查询、替换、启动和停止。 */ diff --git a/src/main/java/com/jimuqu/claw/agent/model/envelope/AttachmentRef.java b/src/main/java/com/jimuqu/claw/agent/model/envelope/AttachmentRef.java index 0f89a81..9ee3aca 100644 --- a/src/main/java/com/jimuqu/claw/agent/model/envelope/AttachmentRef.java +++ b/src/main/java/com/jimuqu/claw/agent/model/envelope/AttachmentRef.java @@ -13,6 +13,7 @@ import java.io.Serializable; @NoArgsConstructor @AllArgsConstructor public class AttachmentRef implements Serializable { + /** 序列化版本号。 */ private static final long serialVersionUID = 1L; /** 附件类别,例如图片、音频或文件。 */ diff --git a/src/main/java/com/jimuqu/claw/agent/model/envelope/InboundEnvelope.java b/src/main/java/com/jimuqu/claw/agent/model/envelope/InboundEnvelope.java index 45f1177..c28e85e 100644 --- a/src/main/java/com/jimuqu/claw/agent/model/envelope/InboundEnvelope.java +++ b/src/main/java/com/jimuqu/claw/agent/model/envelope/InboundEnvelope.java @@ -17,6 +17,7 @@ import java.util.List; @Data @NoArgsConstructor public class InboundEnvelope implements Serializable { + /** 序列化版本号。 */ private static final long serialVersionUID = 1L; /** 上游消息唯一标识。 */ diff --git a/src/main/java/com/jimuqu/claw/agent/model/envelope/OutboundEnvelope.java b/src/main/java/com/jimuqu/claw/agent/model/envelope/OutboundEnvelope.java index 874d0a2..f64c638 100644 --- a/src/main/java/com/jimuqu/claw/agent/model/envelope/OutboundEnvelope.java +++ b/src/main/java/com/jimuqu/claw/agent/model/envelope/OutboundEnvelope.java @@ -14,6 +14,7 @@ import java.util.List; @Data @NoArgsConstructor public class OutboundEnvelope implements Serializable { + /** 序列化版本号。 */ private static final long serialVersionUID = 1L; /** 所属运行任务标识。 */ diff --git a/src/main/java/com/jimuqu/claw/agent/model/event/ChildRunCompletedData.java b/src/main/java/com/jimuqu/claw/agent/model/event/ChildRunCompletedData.java index 211eba7..dd6d337 100644 --- a/src/main/java/com/jimuqu/claw/agent/model/event/ChildRunCompletedData.java +++ b/src/main/java/com/jimuqu/claw/agent/model/event/ChildRunCompletedData.java @@ -11,6 +11,7 @@ import java.io.Serializable; @Data @NoArgsConstructor public class ChildRunCompletedData implements Serializable { + /** 序列化版本号。 */ private static final long serialVersionUID = 1L; /** 父运行标识。 */ diff --git a/src/main/java/com/jimuqu/claw/agent/model/event/ChildRunSpawnedData.java b/src/main/java/com/jimuqu/claw/agent/model/event/ChildRunSpawnedData.java index 8c4895d..6d148be 100644 --- a/src/main/java/com/jimuqu/claw/agent/model/event/ChildRunSpawnedData.java +++ b/src/main/java/com/jimuqu/claw/agent/model/event/ChildRunSpawnedData.java @@ -11,6 +11,7 @@ import java.io.Serializable; @Data @NoArgsConstructor public class ChildRunSpawnedData implements Serializable { + /** 序列化版本号。 */ private static final long serialVersionUID = 1L; /** 父运行标识。 */ diff --git a/src/main/java/com/jimuqu/claw/agent/model/event/ConversationEvent.java b/src/main/java/com/jimuqu/claw/agent/model/event/ConversationEvent.java index 2590ae3..5178e1d 100644 --- a/src/main/java/com/jimuqu/claw/agent/model/event/ConversationEvent.java +++ b/src/main/java/com/jimuqu/claw/agent/model/event/ConversationEvent.java @@ -12,6 +12,7 @@ import java.io.Serializable; @Data @NoArgsConstructor public class ConversationEvent implements Serializable { + /** 序列化版本号。 */ private static final long serialVersionUID = 1L; /** 事件版本号。 */ diff --git a/src/main/java/com/jimuqu/claw/agent/model/event/RunEvent.java b/src/main/java/com/jimuqu/claw/agent/model/event/RunEvent.java index 9abdc9a..2b005ed 100644 --- a/src/main/java/com/jimuqu/claw/agent/model/event/RunEvent.java +++ b/src/main/java/com/jimuqu/claw/agent/model/event/RunEvent.java @@ -12,6 +12,7 @@ import java.io.Serializable; @Data @NoArgsConstructor public class RunEvent implements Serializable { + /** 序列化版本号。 */ private static final long serialVersionUID = 1L; /** 事件序号。 */ diff --git a/src/main/java/com/jimuqu/claw/agent/model/route/LatestReplyRoute.java b/src/main/java/com/jimuqu/claw/agent/model/route/LatestReplyRoute.java index 236a419..8e5a86e 100644 --- a/src/main/java/com/jimuqu/claw/agent/model/route/LatestReplyRoute.java +++ b/src/main/java/com/jimuqu/claw/agent/model/route/LatestReplyRoute.java @@ -11,6 +11,7 @@ import java.io.Serializable; @Data @NoArgsConstructor public class LatestReplyRoute implements Serializable { + /** 序列化版本号。 */ private static final long serialVersionUID = 1L; /** 最近一次路由命中的内部会话键。 */ diff --git a/src/main/java/com/jimuqu/claw/agent/model/route/ReplyTarget.java b/src/main/java/com/jimuqu/claw/agent/model/route/ReplyTarget.java index 9b7613b..344cf0a 100644 --- a/src/main/java/com/jimuqu/claw/agent/model/route/ReplyTarget.java +++ b/src/main/java/com/jimuqu/claw/agent/model/route/ReplyTarget.java @@ -13,6 +13,7 @@ import java.io.Serializable; @Data @NoArgsConstructor public class ReplyTarget implements Serializable { + /** 序列化版本号。 */ private static final long serialVersionUID = 1L; /** 目标所属渠道。 */ diff --git a/src/main/java/com/jimuqu/claw/agent/model/run/AgentRun.java b/src/main/java/com/jimuqu/claw/agent/model/run/AgentRun.java index 1c83fe5..7bf5d41 100644 --- a/src/main/java/com/jimuqu/claw/agent/model/run/AgentRun.java +++ b/src/main/java/com/jimuqu/claw/agent/model/run/AgentRun.java @@ -14,6 +14,7 @@ import java.io.Serializable; @Data @NoArgsConstructor public class AgentRun implements Serializable { + /** 序列化版本号。 */ private static final long serialVersionUID = 1L; /** 运行任务唯一标识。 */ diff --git a/src/main/java/com/jimuqu/claw/agent/runtime/support/AgentTurnRequest.java b/src/main/java/com/jimuqu/claw/agent/runtime/support/AgentTurnRequest.java index e21833a..b0024d9 100644 --- a/src/main/java/com/jimuqu/claw/agent/runtime/support/AgentTurnRequest.java +++ b/src/main/java/com/jimuqu/claw/agent/runtime/support/AgentTurnRequest.java @@ -15,6 +15,7 @@ import java.io.Serializable; @Data @NoArgsConstructor public class AgentTurnRequest implements Serializable { + /** 序列化版本号。 */ private static final long serialVersionUID = 1L; /** 请求来源类型,固定为 JOB_AGENT_TURN。 */ diff --git a/src/main/java/com/jimuqu/claw/agent/runtime/support/ConversationExecutionRequest.java b/src/main/java/com/jimuqu/claw/agent/runtime/support/ConversationExecutionRequest.java index 896ab49..b5d3258 100644 --- a/src/main/java/com/jimuqu/claw/agent/runtime/support/ConversationExecutionRequest.java +++ b/src/main/java/com/jimuqu/claw/agent/runtime/support/ConversationExecutionRequest.java @@ -18,6 +18,7 @@ import java.util.List; @Data @NoArgsConstructor public class ConversationExecutionRequest implements Serializable { + /** 序列化版本号。 */ private static final long serialVersionUID = 1L; /** 当前会话对应的内部键。 */ diff --git a/src/main/java/com/jimuqu/claw/agent/runtime/support/DeliveryResult.java b/src/main/java/com/jimuqu/claw/agent/runtime/support/DeliveryResult.java index 6c9e2ec..4a1d187 100644 --- a/src/main/java/com/jimuqu/claw/agent/runtime/support/DeliveryResult.java +++ b/src/main/java/com/jimuqu/claw/agent/runtime/support/DeliveryResult.java @@ -12,6 +12,7 @@ import java.io.Serializable; @Data @NoArgsConstructor public class DeliveryResult implements Serializable { + /** 序列化版本号。 */ private static final long serialVersionUID = 1L; /** 是否成功发送。 */ diff --git a/src/main/java/com/jimuqu/claw/agent/runtime/support/NotificationResult.java b/src/main/java/com/jimuqu/claw/agent/runtime/support/NotificationResult.java index 166bf2a..b7c16c4 100644 --- a/src/main/java/com/jimuqu/claw/agent/runtime/support/NotificationResult.java +++ b/src/main/java/com/jimuqu/claw/agent/runtime/support/NotificationResult.java @@ -11,6 +11,7 @@ import java.io.Serializable; @Data @NoArgsConstructor public class NotificationResult implements Serializable { + /** 序列化版本号。 */ private static final long serialVersionUID = 1L; /** 是否成功发送。 */ diff --git a/src/main/java/com/jimuqu/claw/agent/runtime/support/ParentRunChildrenSummary.java b/src/main/java/com/jimuqu/claw/agent/runtime/support/ParentRunChildrenSummary.java index c471e31..d96e0e6 100644 --- a/src/main/java/com/jimuqu/claw/agent/runtime/support/ParentRunChildrenSummary.java +++ b/src/main/java/com/jimuqu/claw/agent/runtime/support/ParentRunChildrenSummary.java @@ -14,6 +14,7 @@ import java.util.List; @Data @NoArgsConstructor public class ParentRunChildrenSummary implements Serializable { + /** 序列化版本号。 */ private static final long serialVersionUID = 1L; /** 父运行标识。 */ diff --git a/src/main/java/com/jimuqu/claw/agent/runtime/support/SpawnTaskResult.java b/src/main/java/com/jimuqu/claw/agent/runtime/support/SpawnTaskResult.java index e57aa63..0d90e67 100644 --- a/src/main/java/com/jimuqu/claw/agent/runtime/support/SpawnTaskResult.java +++ b/src/main/java/com/jimuqu/claw/agent/runtime/support/SpawnTaskResult.java @@ -11,6 +11,7 @@ import java.io.Serializable; @Data @NoArgsConstructor public class SpawnTaskResult implements Serializable { + /** 序列化版本号。 */ private static final long serialVersionUID = 1L; /** 新建子运行标识。 */ diff --git a/src/main/java/com/jimuqu/claw/agent/runtime/support/SystemEventRequest.java b/src/main/java/com/jimuqu/claw/agent/runtime/support/SystemEventRequest.java index fe131aa..dc377b4 100644 --- a/src/main/java/com/jimuqu/claw/agent/runtime/support/SystemEventRequest.java +++ b/src/main/java/com/jimuqu/claw/agent/runtime/support/SystemEventRequest.java @@ -14,6 +14,7 @@ import java.io.Serializable; @Data @NoArgsConstructor public class SystemEventRequest implements Serializable { + /** 序列化版本号。 */ private static final long serialVersionUID = 1L; /** 请求来源类型,如定时任务、心跳或子任务 continuation。 */ diff --git a/src/main/java/com/jimuqu/claw/config/SolonClawProperties.java b/src/main/java/com/jimuqu/claw/config/SolonClawProperties.java index b63af67..587031e 100644 --- a/src/main/java/com/jimuqu/claw/config/SolonClawProperties.java +++ b/src/main/java/com/jimuqu/claw/config/SolonClawProperties.java @@ -13,6 +13,7 @@ import java.io.Serializable; @Data @NoArgsConstructor public class SolonClawProperties implements Serializable { + /** 序列化版本号。 */ private static final long serialVersionUID = 1L; /** 工作区目录。 */ diff --git a/src/main/java/com/jimuqu/claw/config/props/AgentProperties.java b/src/main/java/com/jimuqu/claw/config/props/AgentProperties.java index 4ed70fb..32cf0bb 100644 --- a/src/main/java/com/jimuqu/claw/config/props/AgentProperties.java +++ b/src/main/java/com/jimuqu/claw/config/props/AgentProperties.java @@ -11,6 +11,7 @@ import java.io.Serializable; @Data @NoArgsConstructor public class AgentProperties implements Serializable { + /** 序列化版本号。 */ private static final long serialVersionUID = 1L; /** 基础系统提示词。 */ diff --git a/src/main/java/com/jimuqu/claw/config/props/AgentTurnProperties.java b/src/main/java/com/jimuqu/claw/config/props/AgentTurnProperties.java index dce563b..5cd64bd 100644 --- a/src/main/java/com/jimuqu/claw/config/props/AgentTurnProperties.java +++ b/src/main/java/com/jimuqu/claw/config/props/AgentTurnProperties.java @@ -11,6 +11,7 @@ import java.io.Serializable; @Data @NoArgsConstructor public class AgentTurnProperties implements Serializable { + /** 序列化版本号。 */ private static final long serialVersionUID = 1L; /** 默认超时时间,单位秒。 */ diff --git a/src/main/java/com/jimuqu/claw/config/props/BlacklistProperties.java b/src/main/java/com/jimuqu/claw/config/props/BlacklistProperties.java index 41a8f36..70fd5d5 100644 --- a/src/main/java/com/jimuqu/claw/config/props/BlacklistProperties.java +++ b/src/main/java/com/jimuqu/claw/config/props/BlacklistProperties.java @@ -13,6 +13,7 @@ import java.util.List; @Data @NoArgsConstructor public class BlacklistProperties implements Serializable { + /** 序列化版本号。 */ private static final long serialVersionUID = 1L; /** 是否启用黑名单拦截。 */ diff --git a/src/main/java/com/jimuqu/claw/config/props/ChannelsProperties.java b/src/main/java/com/jimuqu/claw/config/props/ChannelsProperties.java index 304c584..46a65d0 100644 --- a/src/main/java/com/jimuqu/claw/config/props/ChannelsProperties.java +++ b/src/main/java/com/jimuqu/claw/config/props/ChannelsProperties.java @@ -11,6 +11,7 @@ import java.io.Serializable; @Data @NoArgsConstructor public class ChannelsProperties implements Serializable { + /** 序列化版本号。 */ private static final long serialVersionUID = 1L; /** 飞书渠道配置。 */ diff --git a/src/main/java/com/jimuqu/claw/config/props/DingTalkProperties.java b/src/main/java/com/jimuqu/claw/config/props/DingTalkProperties.java index a61ef1c..75735f4 100644 --- a/src/main/java/com/jimuqu/claw/config/props/DingTalkProperties.java +++ b/src/main/java/com/jimuqu/claw/config/props/DingTalkProperties.java @@ -13,6 +13,7 @@ import java.util.List; @Data @NoArgsConstructor public class DingTalkProperties implements Serializable { + /** 序列化版本号。 */ private static final long serialVersionUID = 1L; /** 是否启用钉钉渠道。 */ diff --git a/src/main/java/com/jimuqu/claw/config/props/FeishuProperties.java b/src/main/java/com/jimuqu/claw/config/props/FeishuProperties.java index e0ac4bb..00fd80a 100644 --- a/src/main/java/com/jimuqu/claw/config/props/FeishuProperties.java +++ b/src/main/java/com/jimuqu/claw/config/props/FeishuProperties.java @@ -13,6 +13,7 @@ import java.util.List; @Data @NoArgsConstructor public class FeishuProperties implements Serializable { + /** 序列化版本号。 */ private static final long serialVersionUID = 1L; /** 是否启用飞书渠道。 */ diff --git a/src/main/java/com/jimuqu/claw/config/props/HeartbeatProperties.java b/src/main/java/com/jimuqu/claw/config/props/HeartbeatProperties.java index 4e27c5e..b2215dd 100644 --- a/src/main/java/com/jimuqu/claw/config/props/HeartbeatProperties.java +++ b/src/main/java/com/jimuqu/claw/config/props/HeartbeatProperties.java @@ -11,6 +11,7 @@ import java.io.Serializable; @Data @NoArgsConstructor public class HeartbeatProperties implements Serializable { + /** 序列化版本号。 */ private static final long serialVersionUID = 1L; /** 是否启用心跳。 */ diff --git a/src/main/java/com/jimuqu/claw/config/props/JobsProperties.java b/src/main/java/com/jimuqu/claw/config/props/JobsProperties.java index 7b63016..7904be9 100644 --- a/src/main/java/com/jimuqu/claw/config/props/JobsProperties.java +++ b/src/main/java/com/jimuqu/claw/config/props/JobsProperties.java @@ -13,6 +13,7 @@ import java.io.Serializable; @Data @NoArgsConstructor public class JobsProperties implements Serializable { + /** 序列化版本号。 */ private static final long serialVersionUID = 1L; /** systemEvent 任务默认唤醒模式。 */ diff --git a/src/main/java/com/jimuqu/claw/config/props/SchedulerProperties.java b/src/main/java/com/jimuqu/claw/config/props/SchedulerProperties.java index 603ec61..d1916d9 100644 --- a/src/main/java/com/jimuqu/claw/config/props/SchedulerProperties.java +++ b/src/main/java/com/jimuqu/claw/config/props/SchedulerProperties.java @@ -11,6 +11,7 @@ import java.io.Serializable; @Data @NoArgsConstructor public class SchedulerProperties implements Serializable { + /** 序列化版本号。 */ private static final long serialVersionUID = 1L; /** 单会话最大并发数。 */ diff --git a/src/main/java/com/jimuqu/claw/config/props/SubtasksProperties.java b/src/main/java/com/jimuqu/claw/config/props/SubtasksProperties.java index 4c29ba5..5ffbc56 100644 --- a/src/main/java/com/jimuqu/claw/config/props/SubtasksProperties.java +++ b/src/main/java/com/jimuqu/claw/config/props/SubtasksProperties.java @@ -11,6 +11,7 @@ import java.io.Serializable; @Data @NoArgsConstructor public class SubtasksProperties implements Serializable { + /** 序列化版本号。 */ private static final long serialVersionUID = 1L; /** 是否允许子任务继续派生新的子任务。 */ diff --git a/src/main/java/com/jimuqu/claw/config/props/SystemEventsProperties.java b/src/main/java/com/jimuqu/claw/config/props/SystemEventsProperties.java index bb4d96f..a238cea 100644 --- a/src/main/java/com/jimuqu/claw/config/props/SystemEventsProperties.java +++ b/src/main/java/com/jimuqu/claw/config/props/SystemEventsProperties.java @@ -12,6 +12,7 @@ import java.io.Serializable; @Data @NoArgsConstructor public class SystemEventsProperties implements Serializable { + /** 序列化版本号。 */ private static final long serialVersionUID = 1L; /** 通用 system event 的默认策略。 */ diff --git a/src/main/java/com/jimuqu/claw/config/props/ToolsProperties.java b/src/main/java/com/jimuqu/claw/config/props/ToolsProperties.java index dc8f3e4..7ff089f 100644 --- a/src/main/java/com/jimuqu/claw/config/props/ToolsProperties.java +++ b/src/main/java/com/jimuqu/claw/config/props/ToolsProperties.java @@ -11,6 +11,7 @@ import java.io.Serializable; @Data @NoArgsConstructor public class ToolsProperties implements Serializable { + /** 序列化版本号。 */ private static final long serialVersionUID = 1L; /** CLI TerminalSkill 是否启用沙盒模式。 */ diff --git a/src/main/java/com/jimuqu/claw/web/dto/DebugChatRequest.java b/src/main/java/com/jimuqu/claw/web/dto/DebugChatRequest.java index 12e8607..8d0a14c 100644 --- a/src/main/java/com/jimuqu/claw/web/dto/DebugChatRequest.java +++ b/src/main/java/com/jimuqu/claw/web/dto/DebugChatRequest.java @@ -11,6 +11,7 @@ import java.io.Serializable; @Data @NoArgsConstructor public class DebugChatRequest implements Serializable { + /** 序列化版本号。 */ private static final long serialVersionUID = 1L; /** 调试会话标识。 */ diff --git a/src/main/java/com/jimuqu/claw/web/dto/DebugChatResponse.java b/src/main/java/com/jimuqu/claw/web/dto/DebugChatResponse.java index 0103639..4996e8f 100644 --- a/src/main/java/com/jimuqu/claw/web/dto/DebugChatResponse.java +++ b/src/main/java/com/jimuqu/claw/web/dto/DebugChatResponse.java @@ -13,6 +13,7 @@ import java.io.Serializable; @NoArgsConstructor @AllArgsConstructor public class DebugChatResponse implements Serializable { + /** 序列化版本号。 */ private static final long serialVersionUID = 1L; /** 新创建的运行任务标识。 */ diff --git a/src/main/java/com/jimuqu/claw/web/dto/DebugChildRunsResponse.java b/src/main/java/com/jimuqu/claw/web/dto/DebugChildRunsResponse.java index 7abc466..2735b08 100644 --- a/src/main/java/com/jimuqu/claw/web/dto/DebugChildRunsResponse.java +++ b/src/main/java/com/jimuqu/claw/web/dto/DebugChildRunsResponse.java @@ -15,6 +15,7 @@ import java.util.List; @Data @NoArgsConstructor public class DebugChildRunsResponse implements Serializable { + /** 序列化版本号。 */ private static final long serialVersionUID = 1L; /** 父运行下的子任务列表。 */ diff --git a/src/main/java/com/jimuqu/claw/web/dto/DebugRunEventsResponse.java b/src/main/java/com/jimuqu/claw/web/dto/DebugRunEventsResponse.java index eccfa80..00e9404 100644 --- a/src/main/java/com/jimuqu/claw/web/dto/DebugRunEventsResponse.java +++ b/src/main/java/com/jimuqu/claw/web/dto/DebugRunEventsResponse.java @@ -14,6 +14,7 @@ import java.util.List; @Data @NoArgsConstructor public class DebugRunEventsResponse implements Serializable { + /** 序列化版本号。 */ private static final long serialVersionUID = 1L; /** 本次返回的事件列表。 */ diff --git a/src/main/java/com/jimuqu/claw/web/dto/DebugRunResponse.java b/src/main/java/com/jimuqu/claw/web/dto/DebugRunResponse.java index fff6609..0d8dbc8 100644 --- a/src/main/java/com/jimuqu/claw/web/dto/DebugRunResponse.java +++ b/src/main/java/com/jimuqu/claw/web/dto/DebugRunResponse.java @@ -14,6 +14,7 @@ import java.io.Serializable; @NoArgsConstructor @AllArgsConstructor public class DebugRunResponse implements Serializable { + /** 序列化版本号。 */ private static final long serialVersionUID = 1L; /** 当前运行任务详情。 */ -- Gitee